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:
23
.gitlab-ci.yml
Normal file
23
.gitlab-ci.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
stages:
|
||||
- release
|
||||
|
||||
semantic-release:
|
||||
stage: release
|
||||
image: node:lts-alpine
|
||||
id_tokens:
|
||||
NPM_ID_TOKEN:
|
||||
aud: "npm:registry.npmjs.org"
|
||||
SIGSTORE_ID_TOKEN:
|
||||
aud: sigstore
|
||||
before_script:
|
||||
- apk add git openssh
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- npm install
|
||||
- npm run build
|
||||
script:
|
||||
- npx semantic-release
|
||||
only:
|
||||
- main
|
||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
registry=https://registry.npmjs.org/
|
||||
@carealytix:registry=https://registry.npmjs.org/
|
||||
20
.releaserc
Normal file
20
.releaserc
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"branches": [
|
||||
"main"
|
||||
],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
"@semantic-release/npm",
|
||||
"@semantic-release/gitlab",
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
"assets": [
|
||||
"package.json"
|
||||
],
|
||||
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
12
README.md
12
README.md
@@ -31,14 +31,20 @@ Codewalkers coordinates agents from different providers (Claude, Codex, Gemini,
|
||||
### Install
|
||||
|
||||
```sh
|
||||
git clone <repo-url> && cd codewalk-district
|
||||
npm install -g @carealytix/codewalkers
|
||||
```
|
||||
|
||||
This makes the `cw` CLI available globally.
|
||||
|
||||
**Alternative: Install from source**
|
||||
|
||||
```sh
|
||||
git clone git@gitlab.com:carealytix/tools/codewalkers.git && cd codewalkers
|
||||
npm install
|
||||
npm run build
|
||||
npm link
|
||||
```
|
||||
|
||||
This makes the `cw` CLI available globally.
|
||||
|
||||
### Initialize a workspace
|
||||
|
||||
```sh
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
76
apps/server/review/diff-cache.test.ts
Normal file
76
apps/server/review/diff-cache.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
70
apps/server/review/diff-cache.ts
Normal file
70
apps/server/review/diff-cache.ts
Normal 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
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
256
apps/server/test/integration/phase-review-diff.test.ts
Normal file
256
apps/server/test/integration/phase-review-diff.test.ts
Normal 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 (10–20 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');
|
||||
});
|
||||
});
|
||||
@@ -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()];
|
||||
|
||||
@@ -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!)
|
||||
|
||||
@@ -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);
|
||||
|
||||
476
apps/server/test/unit/radar-procedures.test.ts
Normal file
476
apps/server/test/unit/radar-procedures.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
249
apps/server/trpc/routers/phase.test.ts
Normal file
249
apps/server/trpc/routers/phase.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
52
apps/web/src/components/hq/HQResolvingConflictsSection.tsx
Normal file
52
apps/web/src/components/hq/HQResolvingConflictsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
157
apps/web/src/components/radar/CompactionEventsDialog.tsx
Normal file
157
apps/web/src/components/radar/CompactionEventsDialog.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
209
apps/web/src/components/radar/InterAgentMessagesDialog.tsx
Normal file
209
apps/web/src/components/radar/InterAgentMessagesDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
201
apps/web/src/components/radar/QuestionsAskedDialog.tsx
Normal file
201
apps/web/src/components/radar/QuestionsAskedDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
185
apps/web/src/components/radar/SubagentSpawnsDialog.tsx
Normal file
185
apps/web/src/components/radar/SubagentSpawnsDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
7
apps/web/src/components/radar/types.ts
Normal file
7
apps/web/src/components/radar/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface DrilldownDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
agentId: string
|
||||
agentName: string
|
||||
isAgentRunning?: boolean
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
192
apps/web/src/components/review/DiffViewer.test.tsx
Normal file
192
apps/web/src/components/review/DiffViewer.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
270
apps/web/src/components/review/FileCard.test.tsx
Normal file
270
apps/web/src/components/review/FileCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -308,7 +308,7 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
||||
) : (
|
||||
<DiffViewer
|
||||
files={files}
|
||||
comments={[]}
|
||||
commentsByLine={new Map()}
|
||||
onAddComment={() => {}}
|
||||
onResolveComment={() => {}}
|
||||
onUnresolveComment={() => {}}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
|
||||
193
apps/web/src/components/review/ReviewSidebar.test.tsx
Normal file
193
apps/web/src/components/review/ReviewSidebar.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
226
apps/web/src/components/review/ReviewTab.test.tsx
Normal file
226
apps/web/src/components/review/ReviewTab.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
134
apps/web/src/components/review/comment-index.test.tsx
Normal file
134
apps/web/src/components/review/comment-index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
25
apps/web/src/components/review/comment-index.ts
Normal file
25
apps/web/src/components/review/comment-index.ts
Normal 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;
|
||||
}
|
||||
39
apps/web/src/components/review/highlight-worker.ts
Normal file
39
apps/web/src/components/review/highlight-worker.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
29
apps/web/src/components/review/types.test.ts
Normal file
29
apps/web/src/components/review/types.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
240
apps/web/src/components/review/use-syntax-highlight.test.ts
Normal file
240
apps/web/src/components/review/use-syntax-highlight.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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]);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
388
apps/web/src/routes/radar.tsx
Normal file
388
apps/web/src/routes/radar.tsx
Normal 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
@@ -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",
|
||||
|
||||
@@ -245,8 +245,8 @@ Index: `(phaseId)`.
|
||||
| ProjectRepository | + junction ops: setInitiativeProjects (diff-based), findProjectsByInitiativeId |
|
||||
| AccountRepository | + findNextAvailable (round-robin), markExhausted, clearExpiredExhaustion |
|
||||
| ProposalRepository | + findByAgentIdAndStatus, updateManyByAgentId, countByAgentIdAndStatus |
|
||||
| LogChunkRepository | insertChunk, findByAgentId, deleteByAgentId, getSessionCount |
|
||||
| ConversationRepository | create, findById, findPendingForAgent, answer |
|
||||
| LogChunkRepository | insertChunk, findByAgentId, findByAgentIds (batch), deleteByAgentId, getSessionCount |
|
||||
| ConversationRepository | create, findById, findPendingForAgent, answer, countByFromAgentIds (batch), findByFromAgentId |
|
||||
| ChatSessionRepository | createSession, findActiveSession, findActiveSessionByAgentId, updateSession, createMessage, findMessagesBySessionId |
|
||||
| ReviewCommentRepository | create, findByPhaseId, resolve, unresolve, delete |
|
||||
| ErrandRepository | create, findById, findAll (filter by projectId/status), update, delete |
|
||||
|
||||
@@ -117,6 +117,15 @@ InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId }
|
||||
| `agent:crashed` | Auto-retry crashed task up to `MAX_TASK_RETRIES` (3). Increments `retryCount`, resets status to `pending`, re-queues. Exceeding retries leaves task `in_progress` for manual intervention. |
|
||||
| `task:completed` | Merge task branch (if branch exists), check phase completion, dispatch next queued task |
|
||||
|
||||
### Conflict Resolution → Dispatch Flow
|
||||
|
||||
When a task branch merge produces conflicts:
|
||||
1. `mergeTaskIntoPhase()` detects conflicts from `branchManager.mergeBranch()`
|
||||
2. Calls `conflictResolutionService.handleConflict()` which creates a "Resolve conflicts" task (with dedup — skips if an identical pending/in_progress resolution task already exists)
|
||||
3. The original task is **not blocked** — it was already completed by `handleAgentStopped` before the merge attempt. The pending resolution task prevents premature phase completion.
|
||||
4. Orchestrator queues the new conflict task via `dispatchManager.queue()`
|
||||
5. `scheduleDispatch()` picks it up and assigns it to an idle agent
|
||||
|
||||
### Crash Recovery
|
||||
|
||||
When an agent crashes (`agent:crashed` event), the orchestrator automatically retries the task:
|
||||
@@ -125,7 +134,10 @@ When an agent crashes (`agent:crashed` event), the orchestrator automatically re
|
||||
3. If under limit: increments `retryCount`, resets task to `pending`, re-queues for dispatch
|
||||
4. If over limit: logs warning, task stays `in_progress` for manual intervention
|
||||
|
||||
On server restart, `recoverDispatchQueues()` also recovers stuck `in_progress` tasks whose agents are dead (status is not `running` or `waiting_for_input`). These are reset to `pending` and re-queued.
|
||||
On server restart, `recoverDispatchQueues()` also recovers:
|
||||
- Stuck `in_progress` tasks whose agents are dead (status is not `running` or `waiting_for_input`) — reset to `pending` and re-queued
|
||||
- Erroneously `blocked` tasks whose agents completed successfully (status is `idle` or `stopped`) — marked `completed` so the phase can progress. This handles the legacy case where conflict resolution incorrectly blocked already-completed tasks.
|
||||
- Fully-completed `in_progress` phases — after task recovery, if all tasks in an `in_progress` phase are completed, triggers `handlePhaseAllTasksDone` to complete/review the phase
|
||||
|
||||
Manual retry via `retryBlockedTask()` resets `retryCount` to 0, giving the task a fresh set of automatic retries.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| Tiptap | Rich text editor (ProseMirror-based) |
|
||||
| Lucide | Icon library |
|
||||
| Geist Sans/Mono | Typography (variable fonts in `public/fonts/`) |
|
||||
| react-window 2.x | Virtualized list rendering for large file trees in ReviewSidebar |
|
||||
|
||||
## Design System (v2)
|
||||
|
||||
@@ -43,6 +44,7 @@ Use `mapEntityStatus(rawStatus)` from `StatusDot.tsx` to convert raw entity stat
|
||||
| Route | Component | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| `/` | `routes/index.tsx` | Dashboard / initiative list |
|
||||
| `/hq` | `routes/hq.tsx` | Headquarters — action items requiring user attention |
|
||||
| `/initiatives/$id` | `routes/initiatives/$initiativeId.tsx` | Initiative detail (tabbed) |
|
||||
| `/agents` | `routes/agents.tsx` | Agent list with Output / Details tab panel |
|
||||
| `/settings` | `routes/settings/index.tsx` | Settings page |
|
||||
@@ -113,15 +115,26 @@ The initiative detail page has three tabs managed via local state (not URL param
|
||||
### Review Components (`src/components/review/`)
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview. Phase-level review has threaded inline comments (with reply support) + Request Changes; initiative-level review has Request Changes (summary prompt) + Push Branch / Merge & Push |
|
||||
| `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats, preview controls, approve/reject actions |
|
||||
| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation |
|
||||
| `DiffViewer` | Unified diff renderer with threaded inline comments (root + reply threads) |
|
||||
| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview. Phase-level review has threaded inline comments (with reply support) + Request Changes; initiative-level review has Request Changes (summary prompt) + Push Branch / Merge & Push. Phase diff uses metadata-only `FileDiff[]` from `getPhaseReviewDiff`; commit diff parses `rawDiff` via `parseUnifiedDiff` → `FileDiffDetail[]`. Passes `commitMode`, `phaseId`, `expandAll` to DiffViewer |
|
||||
| `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats (uses `totalAdditions`/`totalDeletions` props when available, falls back to summing files), preview controls, Expand all button, approve/reject actions |
|
||||
| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation. FilesView uses react-window 2.x `List` for virtualized rendering when the row count exceeds 50 (dir-headers + file rows). Scroll position is preserved across Files ↔ Commits tab switches. Directories are collapsible. Clicking a file scrolls the virtual list to that row. |
|
||||
| `DiffViewer` | Unified diff renderer with threaded inline comments (root + reply threads). Accepts `FileDiff[] | FileDiffDetail[]`, `phaseId`, `commitMode`, `expandAll` props |
|
||||
| `CommentThread` | Renders root comment with resolve/reopen + nested reply threads (agent replies styled with primary border). Inline reply form |
|
||||
| `ConflictResolutionPanel` | Merge conflict detection + agent resolution in initiative review. Shows conflict files, spawns conflict agent, inline questions, re-check on completion |
|
||||
| `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) |
|
||||
| `ProposalCard` | Individual proposal display |
|
||||
|
||||
#### Syntax Highlighting (`use-syntax-highlight.ts` + `highlight-worker.ts`)
|
||||
|
||||
`useHighlightedFile(filePath, allLines)` returns `LineTokenMap | null`. Tokenisation runs off the main thread:
|
||||
|
||||
- **Worker path** (default): a module-level pool of 2 ES module Web Workers (`highlight-worker.ts`) each import shiki's `codeToTokens` dynamically. Requests are round-robined by `requestCount % 2`. Responses are correlated by UUID. Late responses after unmount are silently discarded via the `pending` Map.
|
||||
- **Fallback path** (CSP / browser-compat): if `Worker` construction throws, `createHighlighter` is used on the main thread but processes 200 lines per chunk, yielding between chunks via `scheduler.yield()` or `setTimeout(0)`.
|
||||
|
||||
Callers receive `null` while highlighting is in progress and a populated `Map<lineNumber, ThemedToken[]>` once it resolves. `LineWithComments` already renders plain text when `null`, so no caller changes are needed.
|
||||
|
||||
Vite must be configured with `worker.format: 'es'` (added to `vite.config.ts`) for the worker chunk to bundle correctly alongside code-split app chunks.
|
||||
|
||||
### UI Primitives (`src/components/ui/`)
|
||||
shadcn/ui components: badge (6 status variants + xs size), button, card, dialog, dropdown-menu, input, label, select, sonner, textarea, tooltip.
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a
|
||||
| `ensureBranch(repoPath, branch, baseBranch)` | Create branch from base if it doesn't exist (idempotent) |
|
||||
| `mergeBranch(repoPath, source, target)` | Merge via ephemeral worktree, returns conflict info |
|
||||
| `diffBranches(repoPath, base, head)` | Three-dot diff between branches |
|
||||
| `diffBranchesStat(repoPath, base, head)` | Per-file metadata (path, status, additions, deletions) — no hunk content. Binary files included with `status: 'binary'` and counts of 0. Returns `FileStatEntry[]`. |
|
||||
| `diffFileSingle(repoPath, base, head, filePath)` | Raw unified diff for a single file (three-dot diff). `filePath` must be URL-decoded. Returns empty string for binary files. |
|
||||
| `deleteBranch(repoPath, branch)` | Delete local branch (no-op if missing) |
|
||||
| `branchExists(repoPath, branch)` | Check local branches |
|
||||
| `remoteBranchExists(repoPath, branch)` | Check remote tracking branches (`origin/<branch>`) |
|
||||
|
||||
@@ -69,6 +69,10 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
||||
| getActiveRefineAgent | query | Active refine agent for initiative |
|
||||
| getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) |
|
||||
| listWaitingAgents | query | Agents waiting for input |
|
||||
| listForRadar | query | Radar page: per-agent metrics (questionsCount, messagesCount, subagentsCount, compactionsCount) with time/status/mode/initiative filters |
|
||||
| getCompactionEvents | query | Compaction events for one agent: `{agentId}` → `{timestamp, sessionNumber}[]` (cap 200) |
|
||||
| getSubagentSpawns | query | Subagent spawn events for one agent: `{agentId}` → `{timestamp, description, promptPreview, fullPrompt}[]` (cap 200) |
|
||||
| getQuestionsAsked | query | AskUserQuestion tool calls for one agent: `{agentId}` → `{timestamp, questions[]}[]` (cap 200) |
|
||||
| onAgentOutput | subscription | Live raw JSONL output stream via EventBus |
|
||||
|
||||
### Tasks
|
||||
@@ -118,7 +122,8 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
||||
| listInitiativePhaseDependencies | query | All dependency edges |
|
||||
| getPhaseDependencies | query | What this phase depends on |
|
||||
| getPhaseDependents | query | What depends on this phase |
|
||||
| getPhaseReviewDiff | query | Full branch diff for pending_review phase |
|
||||
| getPhaseReviewDiff | query | File-level metadata for pending_review phase: `{phaseName, sourceBranch, targetBranch, files: FileStatEntry[], totalAdditions, totalDeletions}` — no hunk content. Results are cached in-memory by `phaseId:headHash` (TTL: `REVIEW_DIFF_CACHE_TTL_MS`, default 5 min). Cache is invalidated when a task merges into the phase branch. |
|
||||
| getFileDiff | query | Per-file unified diff on demand: `{phaseId, filePath, projectId?}` → `{binary: boolean, rawDiff: string}`; `filePath` must be URL-encoded; binary files return `{binary: true, rawDiff: ''}` |
|
||||
| getPhaseReviewCommits | query | List commits between initiative and phase branch |
|
||||
| getCommitDiff | query | Diff for a single commit (by hash) in a phase |
|
||||
| approvePhaseReview | mutation | Approve and merge phase branch |
|
||||
@@ -254,6 +259,7 @@ Inter-agent communication for parallel agents.
|
||||
| `getPendingConversations` | query | Poll for incoming questions: `{agentId}` → Conversation[] |
|
||||
| `getConversation` | query | Get conversation by ID: `{id}` → Conversation |
|
||||
| `answerConversation` | mutation | Answer a conversation: `{id, answer}` → Conversation |
|
||||
| `getByFromAgent` | query | Radar drilldown: all conversations sent by agent: `{agentId}` → `{id, timestamp, toAgentName, toAgentId, question, answer, status, taskId, phaseId}[]` (cap 200) |
|
||||
|
||||
Target resolution: `toAgentId` → direct; `taskId` → find running agent by task; `phaseId` → find running agent by any task in phase.
|
||||
|
||||
@@ -273,33 +279,13 @@ Persistent chat loop for iterative phase/task refinement via agent.
|
||||
|
||||
Context dependency: `requireChatSessionRepository(ctx)`, `requireAgentManager(ctx)`, `requireInitiativeRepository(ctx)`, `requireTaskRepository(ctx)`.
|
||||
|
||||
## Errand Procedures
|
||||
|
||||
Small isolated changes that spawn a dedicated agent in a git worktree. Errands are scoped to a project and use a branch named `cw/errand/<slug>-<8-char-id>`.
|
||||
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `errand.create` | mutation | Create errand: `{description, projectId, baseBranch?}` → `{id, branch, agentId}`. Creates branch, worktree, DB record, spawns agent. |
|
||||
| `errand.list` | query | List errands: `{projectId?, status?}` → ErrandWithAlias[] (ordered newest-first) |
|
||||
| `errand.get` | query | Get errand by ID: `{id}` → ErrandWithAlias with `projectPath: string \| null` (computed from workspaceRoot) |
|
||||
| `errand.diff` | query | Get branch diff: `{id}` → `{diff: string}` |
|
||||
| `errand.complete` | mutation | Mark active errand ready for review (stops agent): `{id}` → Errand |
|
||||
| `errand.merge` | mutation | Merge errand branch: `{id, target?}` → `{status: 'merged'}` or throws conflict |
|
||||
| `errand.delete` | mutation | Delete errand and clean up worktree/branch: `{id}` → `{success: true}` |
|
||||
| `errand.sendMessage` | mutation | Send message to running errand agent: `{id, message}` → `{success: true}` |
|
||||
| `errand.abandon` | mutation | Abandon errand (stop agent, clean up, set status): `{id}` → Errand |
|
||||
|
||||
**Errand statuses**: `active` → `pending_review` (via complete) → `merged` (via merge) or `conflict` (merge failed) → retry merge. `abandoned` is terminal. Only `pending_review` and `conflict` errands can be merged.
|
||||
|
||||
Context dependencies: `requireErrandRepository(ctx)`, `requireProjectRepository(ctx)`, `requireAgentManager(ctx)`, `requireBranchManager(ctx)`, `ctx.workspaceRoot` (for `ensureProjectClone`). `SimpleGitWorktreeManager` is created on-the-fly per project clone path.
|
||||
|
||||
## Headquarters Procedures
|
||||
|
||||
Composite dashboard query aggregating all action items that require user intervention.
|
||||
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `getHeadquartersDashboard` | query | Returns 5 typed arrays of action items (no input required) |
|
||||
| `getHeadquartersDashboard` | query | Returns 6 typed arrays of action items (no input required) |
|
||||
|
||||
### Return Shape
|
||||
|
||||
@@ -309,6 +295,7 @@ Composite dashboard query aggregating all action items that require user interve
|
||||
pendingReviewInitiatives: Array<{ initiativeId, initiativeName, since }>;
|
||||
pendingReviewPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, since }>;
|
||||
planningInitiatives: Array<{ initiativeId, initiativeName, pendingPhaseCount, since }>;
|
||||
resolvingConflicts: Array<{ initiativeId, initiativeName, agentId, agentName, agentStatus, since }>;
|
||||
blockedPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, lastMessage, since }>;
|
||||
}
|
||||
```
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -73,12 +73,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"
|
||||
@@ -5198,6 +5200,15 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-window": {
|
||||
"version": "1.8.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
|
||||
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -9131,6 +9142,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-window": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
|
||||
"integrity": "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
||||
31
package.json
31
package.json
@@ -1,15 +1,28 @@
|
||||
{
|
||||
"name": "codewalk-district",
|
||||
"name": "@carealytix/codewalkers",
|
||||
"version": "0.0.1",
|
||||
"description": "Multi-agent workspace for orchestrating multiple Claude Code agents",
|
||||
"description": "Multi-agent workspace for orchestrating multiple AI coding agents",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@gitlab.com/carealytix/tools/codewalkers.git"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "restricted"
|
||||
},
|
||||
"files": [
|
||||
"apps/server/dist/",
|
||||
"drizzle/"
|
||||
],
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"main": "./apps/server/dist/index.js",
|
||||
"bin": {
|
||||
"cw": "./apps/server/dist/bin/cw.js"
|
||||
"cw": "apps/server/dist/bin/cw.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
@@ -18,7 +31,8 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"dev:web": "npm run dev --workspace=apps/web"
|
||||
"dev:web": "npm run dev --workspace=apps/web",
|
||||
"release": "semantic-release"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
@@ -27,7 +41,6 @@
|
||||
"multi-agent"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.19.0",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
@@ -62,6 +75,12 @@
|
||||
"rimraf": "^6.0.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.0.18",
|
||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/gitlab": "^13.3.2",
|
||||
"@semantic-release/npm": "^13.1.5",
|
||||
"@semantic-release/release-notes-generator": "^14.1.0",
|
||||
"semantic-release": "^25.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,24 @@ import path from 'node:path';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// Alias react to the parent monorepo's copy, matching what @testing-library
|
||||
// loads react-dom from. This ensures React DOM and our components share the
|
||||
// same ReactSharedInternals and hook dispatcher — preventing null-dispatcher
|
||||
// errors when running tests from a git worktree.
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './apps/web/src'),
|
||||
react: path.resolve(__dirname, '../../../../node_modules/react'),
|
||||
'react-dom': path.resolve(__dirname, '../../../../node_modules/react-dom'),
|
||||
},
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
test: {
|
||||
// Force react-dom and @testing-library through Vite's module graph so that
|
||||
// the resolve.alias for 'react-dom' applies (prevents parent-monorepo
|
||||
// react-dom loading a different React instance than our source files).
|
||||
deps: {
|
||||
inline: ['react-dom', '@testing-library/react'],
|
||||
},
|
||||
// Enable test globals (describe, it, expect without imports)
|
||||
globals: true,
|
||||
env: {
|
||||
@@ -17,7 +30,7 @@ export default defineConfig({
|
||||
},
|
||||
// Test file pattern
|
||||
include: ['**/*.test.ts', '**/*.test.tsx'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**', 'packages/**'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**', 'packages/**', 'workdir/**'],
|
||||
environmentMatchGlobs: [
|
||||
['apps/web/**', 'happy-dom'],
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user