Merge branch 'main' into cw/continuous-code-quality-conflict-1772832123778

# Conflicts:
#	apps/server/drizzle/meta/0037_snapshot.json
#	apps/server/drizzle/meta/_journal.json
This commit is contained in:
Lukas May
2026-03-06 22:30:21 +01:00
39 changed files with 5291 additions and 419 deletions

View File

@@ -369,6 +369,7 @@ export class MultiProviderAgentManager implements AgentManager {
agentId, pid,
() => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer,
this.createEarlyCompletionChecker(agentId),
);
activeEntry.cancelPoll = cancel;
@@ -406,6 +407,20 @@ export class MultiProviderAgentManager implements AgentManager {
return this.toAgentInfo(agent);
}
/**
* Create a callback that checks if an agent has a valid signal.json,
* used by pollForCompletion to detect hung processes.
*/
private createEarlyCompletionChecker(agentId: string): () => Promise<boolean> {
return async () => {
const agent = await this.repository.findById(agentId);
if (!agent?.worktreeId) return false;
const agentWorkdir = this.processManager.getAgentWorkdir(agent.worktreeId);
const signal = await this.outputHandler.readSignalCompletion(agentWorkdir);
return signal !== null;
};
}
/**
* Handle completion of a detached agent.
*/
@@ -525,6 +540,7 @@ export class MultiProviderAgentManager implements AgentManager {
agentId, pid,
() => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer,
this.createEarlyCompletionChecker(agentId),
);
commitActiveEntry.cancelPoll = commitCancel;
@@ -633,6 +649,7 @@ export class MultiProviderAgentManager implements AgentManager {
agentId, pid,
() => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer,
this.createEarlyCompletionChecker(agentId),
);
activeEntry.cancelPoll = cancel;
@@ -704,6 +721,7 @@ export class MultiProviderAgentManager implements AgentManager {
agentId, pid,
() => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer,
this.createEarlyCompletionChecker(agentId),
);
activeEntry.cancelPoll = cancel;
@@ -890,6 +908,7 @@ export class MultiProviderAgentManager implements AgentManager {
agentId, pid,
() => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer,
this.createEarlyCompletionChecker(agentId),
);
resumeActiveEntry.cancelPoll = resumeCancel;
}
@@ -1013,6 +1032,7 @@ export class MultiProviderAgentManager implements AgentManager {
agentId, pid,
() => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer,
this.createEarlyCompletionChecker(agentId),
);
const active = this.activeAgents.get(agentId);
if (active) active.cancelPoll = cancel;

View File

@@ -1133,7 +1133,7 @@ export class OutputHandler {
* Uses SignalManager for atomic read-and-validate when available.
* Returns the raw JSON string on success, null if missing/invalid.
*/
private async readSignalCompletion(agentWorkdir: string): Promise<string | null> {
async readSignalCompletion(agentWorkdir: string): Promise<string | null> {
// Prefer SignalManager (unified implementation with proper validation)
if (this.signalManager) {
const signal = await this.signalManager.readSignal(agentWorkdir);

View File

@@ -328,27 +328,63 @@ export class ProcessManager {
* When the process exits, calls onComplete callback.
* Returns a cancel handle to stop polling (e.g. on agent cleanup or re-resume).
*
* Optionally checks signal.json after a grace period to detect hung processes
* that completed work but failed to exit. If a valid signal is found while the
* process is still alive, SIGTERM is sent and normal completion proceeds.
*
* @param onComplete - Called when the process is no longer alive
* @param getTailer - Function to get the current tailer for final flush
* @param checkEarlyCompletion - Optional callback that returns true if signal.json indicates completion
*/
pollForCompletion(
agentId: string,
pid: number,
onComplete: () => Promise<void>,
getTailer: () => FileTailer | undefined,
checkEarlyCompletion?: () => Promise<boolean>,
): { cancel: () => void } {
let cancelled = false;
const startTime = Date.now();
const GRACE_PERIOD_MS = 60_000;
const SIGNAL_CHECK_INTERVAL_MS = 30_000;
let lastSignalCheck = 0;
const finalize = async () => {
const tailer = getTailer();
if (tailer) {
await new Promise((resolve) => setTimeout(resolve, 500));
await tailer.stop();
}
if (!cancelled) await onComplete();
};
const check = async () => {
if (cancelled) return;
if (!isPidAlive(pid)) {
const tailer = getTailer();
if (tailer) {
await new Promise((resolve) => setTimeout(resolve, 500));
await tailer.stop();
}
if (!cancelled) await onComplete();
await finalize();
return;
}
// Defensive signal check: after grace period, periodically check signal.json
if (checkEarlyCompletion) {
const elapsed = Date.now() - startTime;
if (elapsed >= GRACE_PERIOD_MS && Date.now() - lastSignalCheck >= SIGNAL_CHECK_INTERVAL_MS) {
lastSignalCheck = Date.now();
try {
const hasSignal = await checkEarlyCompletion();
if (hasSignal) {
log.warn({ agentId, pid, elapsedMs: elapsed }, 'signal.json found but process still alive — sending SIGTERM');
try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
await new Promise((resolve) => setTimeout(resolve, 2000));
await finalize();
return;
}
} catch (err) {
log.debug({ agentId, err: err instanceof Error ? err.message : String(err) }, 'early completion check failed');
}
}
}
if (!cancelled) setTimeout(check, 1000);
};
check();

View File

@@ -13,6 +13,8 @@ import { createDefaultTrpcClient } from './trpc-client.js';
import { createContainer } from '../container.js';
import { findWorkspaceRoot, writeCwrc, defaultCwConfig } from '../config/index.js';
import { createModuleLogger } from '../logger/index.js';
import { backfillMetricsFromPath } from '../scripts/backfill-metrics.js';
import { getDbPath } from '../db/index.js';
/** Environment variable for custom port */
const CW_PORT_ENV = 'CW_PORT';
@@ -134,6 +136,22 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
}
});
// Backfill metrics command (standalone — no server, no tRPC)
program
.command('backfill-metrics')
.description('Populate agent_metrics table from existing agent_log_chunks (run once after upgrading)')
.option('--db <path>', 'Path to the SQLite database file (defaults to configured DB path)')
.action(async (options: { db?: string }) => {
const dbPath = options.db ?? getDbPath();
console.log(`Backfilling metrics from ${dbPath}...`);
try {
await backfillMetricsFromPath(dbPath);
} catch (error) {
console.error('Backfill failed:', (error as Error).message);
process.exit(1);
}
});
// Agent command group
const agentCommand = program
.command('agent')

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { createTestDatabase } from './test-helpers.js';
import { agentMetrics } from '../../schema.js';
describe('agentMetrics table', () => {
it('select from empty agentMetrics returns []', async () => {
const db = createTestDatabase();
const rows = await db.select().from(agentMetrics);
expect(rows).toEqual([]);
});
it('insert and select a metrics row round-trips correctly', async () => {
const db = createTestDatabase();
await db.insert(agentMetrics).values({
agentId: 'agent-abc',
questionsCount: 3,
subagentsCount: 1,
compactionsCount: 0,
updatedAt: new Date('2024-01-01T00:00:00Z'),
});
const rows = await db.select().from(agentMetrics);
expect(rows).toHaveLength(1);
expect(rows[0].agentId).toBe('agent-abc');
expect(rows[0].questionsCount).toBe(3);
expect(rows[0].subagentsCount).toBe(1);
expect(rows[0].compactionsCount).toBe(0);
});
it('agentId is primary key — duplicate insert throws', async () => {
const db = createTestDatabase();
await db.insert(agentMetrics).values({
agentId: 'agent-dup',
questionsCount: 0,
subagentsCount: 0,
compactionsCount: 0,
updatedAt: new Date(),
});
await expect(
db.insert(agentMetrics).values({
agentId: 'agent-dup',
questionsCount: 1,
subagentsCount: 0,
compactionsCount: 0,
updatedAt: new Date(),
})
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleLogChunkRepository } from './log-chunk.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
describe('DrizzleLogChunkRepository', () => {
let db: DrizzleDatabase;
let repo: DrizzleLogChunkRepository;
const testAgentId = 'agent-test-001';
beforeEach(() => {
db = createTestDatabase();
repo = new DrizzleLogChunkRepository(db);
});
it('AskUserQuestion chunk — questionsCount upserted correctly', async () => {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}] } }),
});
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([{
agentId: testAgentId,
questionsCount: 2,
subagentsCount: 0,
compactionsCount: 0,
}]);
});
it('Agent tool chunk — subagentsCount incremented', async () => {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'tool_use', name: 'Agent' }),
});
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([{
agentId: testAgentId,
questionsCount: 0,
subagentsCount: 1,
compactionsCount: 0,
}]);
});
it('Compaction event — compactionsCount incremented', async () => {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'system', subtype: 'init', source: 'compact' }),
});
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([{
agentId: testAgentId,
questionsCount: 0,
subagentsCount: 0,
compactionsCount: 1,
}]);
});
it('Irrelevant chunk type — no metrics row created', async () => {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'text', text: 'hello' }),
});
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([]);
});
it('Malformed JSON chunk — chunk persisted, metrics row absent', async () => {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: 'not-valid-json',
});
const chunks = await repo.findByAgentId(testAgentId);
expect(chunks).toHaveLength(1);
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([]);
});
it('Multiple inserts, same agent — counts accumulate additively', async () => {
// 3 Agent tool chunks
for (let i = 0; i < 3; i++) {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'tool_use', name: 'Agent' }),
});
}
// 1 AskUserQuestion with 2 questions
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}] } }),
});
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([{
agentId: testAgentId,
questionsCount: 2,
subagentsCount: 3,
compactionsCount: 0,
}]);
});
it('findMetricsByAgentIds with empty array — returns []', async () => {
const metrics = await repo.findMetricsByAgentIds([]);
expect(metrics).toEqual([]);
});
it('findMetricsByAgentIds with agentId that has no metrics row — returns []', async () => {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'text', text: 'hello' }),
});
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([]);
});
});

View File

@@ -4,10 +4,10 @@
* Implements LogChunkRepository interface using Drizzle ORM.
*/
import { eq, asc, max, inArray } from 'drizzle-orm';
import { eq, asc, max, inArray, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { agentLogChunks } from '../../schema.js';
import { agentLogChunks, agentMetrics } from '../../schema.js';
import type { LogChunkRepository } from '../log-chunk-repository.js';
export class DrizzleLogChunkRepository implements LogChunkRepository {
@@ -19,13 +19,58 @@ export class DrizzleLogChunkRepository implements LogChunkRepository {
sessionNumber: number;
content: string;
}): Promise<void> {
await this.db.insert(agentLogChunks).values({
id: nanoid(),
agentId: data.agentId,
agentName: data.agentName,
sessionNumber: data.sessionNumber,
content: data.content,
createdAt: new Date(),
// better-sqlite3 is synchronous — transaction callback must be sync, use .run() not await
this.db.transaction((tx) => {
// 1. Always insert the chunk row first
tx.insert(agentLogChunks).values({
id: nanoid(),
agentId: data.agentId,
agentName: data.agentName,
sessionNumber: data.sessionNumber,
content: data.content,
createdAt: new Date(),
}).run();
// 2. Parse content and determine metric increments
// Wrap only the parse + upsert block — chunk insert is not rolled back on parse failure
try {
const parsed = JSON.parse(data.content);
let deltaQuestions = 0;
let deltaSubagents = 0;
let deltaCompactions = 0;
if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') {
deltaQuestions = parsed.input?.questions?.length ?? 0;
} else if (parsed.type === 'tool_use' && parsed.name === 'Agent') {
deltaSubagents = 1;
} else if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') {
deltaCompactions = 1;
}
// 3. Only upsert if there is something to increment
if (deltaQuestions > 0 || deltaSubagents > 0 || deltaCompactions > 0) {
tx.insert(agentMetrics)
.values({
agentId: data.agentId,
questionsCount: deltaQuestions,
subagentsCount: deltaSubagents,
compactionsCount: deltaCompactions,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: agentMetrics.agentId,
set: {
questionsCount: sql`${agentMetrics.questionsCount} + ${deltaQuestions}`,
subagentsCount: sql`${agentMetrics.subagentsCount} + ${deltaSubagents}`,
compactionsCount: sql`${agentMetrics.compactionsCount} + ${deltaCompactions}`,
updatedAt: new Date(),
},
})
.run();
}
} catch {
// Malformed JSON — skip metric upsert, chunk insert already committed within transaction
}
});
}
@@ -69,4 +114,22 @@ export class DrizzleLogChunkRepository implements LogChunkRepository {
return result[0]?.maxSession ?? 0;
}
async findMetricsByAgentIds(agentIds: string[]): Promise<{
agentId: string;
questionsCount: number;
subagentsCount: number;
compactionsCount: number;
}[]> {
if (agentIds.length === 0) return [];
return this.db
.select({
agentId: agentMetrics.agentId,
questionsCount: agentMetrics.questionsCount,
subagentsCount: agentMetrics.subagentsCount,
compactionsCount: agentMetrics.compactionsCount,
})
.from(agentMetrics)
.where(inArray(agentMetrics.agentId, agentIds));
}
}

View File

@@ -27,4 +27,16 @@ export interface LogChunkRepository {
deleteByAgentId(agentId: string): Promise<void>;
getSessionCount(agentId: string): Promise<number>;
/**
* Batch-fetch pre-computed metrics for multiple agent IDs.
* Returns one row per agent that has metrics. Agents with no
* matching row in agent_metrics are omitted (not returned as zeros).
*/
findMetricsByAgentIds(agentIds: string[]): Promise<{
agentId: string;
questionsCount: number;
subagentsCount: number;
compactionsCount: number;
}[]>;
}

View File

@@ -514,6 +514,21 @@ export const agentLogChunks = sqliteTable('agent_log_chunks', {
export type AgentLogChunk = InferSelectModel<typeof agentLogChunks>;
export type NewAgentLogChunk = InferInsertModel<typeof agentLogChunks>;
// ============================================================================
// AGENT METRICS
// ============================================================================
export const agentMetrics = sqliteTable('agent_metrics', {
agentId: text('agent_id').primaryKey(),
questionsCount: integer('questions_count').notNull().default(0),
subagentsCount: integer('subagents_count').notNull().default(0),
compactionsCount: integer('compactions_count').notNull().default(0),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export type AgentMetrics = InferSelectModel<typeof agentMetrics>;
export type NewAgentMetrics = InferInsertModel<typeof agentMetrics>;
// ============================================================================
// CONVERSATIONS (inter-agent communication)
// ============================================================================

View File

@@ -0,0 +1,7 @@
CREATE TABLE `agent_metrics` (
`agent_id` text PRIMARY KEY NOT NULL,
`questions_count` integer DEFAULT 0 NOT NULL,
`subagents_count` integer DEFAULT 0 NOT NULL,
`compactions_count` integer DEFAULT 0 NOT NULL,
`updated_at` integer NOT NULL
);

View File

@@ -0,0 +1 @@
ALTER TABLE `initiatives` ADD `quality_review` integer DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ef1d4ce7-a9c4-4e86-9cac-2e9cbbd0e688",
"prevId": "c84e499f-7df8-4091-b2a5-6b12847898bd",
"id": "eb30417e-d030-457f-911e-6566dce54fc9",
"prevId": "f85b9df3-dead-4c46-90ac-cf36bcaa6eb4",
"tables": {
"accounts": {
"name": "accounts",
@@ -155,6 +155,54 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"agent_metrics": {
"name": "agent_metrics",
"columns": {
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"questions_count": {
"name": "questions_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"subagents_count": {
"name": "subagents_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"compactions_count": {
"name": "compactions_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"agents": {
"name": "agents",
"columns": {
@@ -1137,14 +1185,6 @@
"autoincrement": false,
"default": "'review_per_phase'"
},
"quality_review": {
"name": "quality_review",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",

File diff suppressed because it is too large Load Diff

View File

@@ -264,8 +264,15 @@
{
"idx": 37,
"version": "6",
"when": 1772828694292,
"tag": "0037_eager_devos",
"breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1772829916655,
"tag": "0037_worthless_princess_powerful",
"tag": "0038_worthless_princess_powerful",
"breakpoints": true
}
]

View File

@@ -21,7 +21,7 @@ import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { AgentManager } from '../agent/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js';
import { phaseBranchName, taskBranchName } from '../git/branch-naming.js';
import { phaseBranchName, taskBranchName, isPlanningCategory } 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';
@@ -708,6 +708,23 @@ export class ExecutionOrchestrator {
}
}
// Clean up stale duplicate planning tasks (e.g. a crashed detail task
// that was reset to pending, then a new detail task was created and completed).
const tasksAfterRecovery = await this.taskRepository.findByPhaseId(phase.id);
const completedPlanningNames = new Set<string>();
for (const t of tasksAfterRecovery) {
if (isPlanningCategory(t.category) && t.status === 'completed') {
completedPlanningNames.add(`${t.category}:${t.phaseId}`);
}
}
for (const t of tasksAfterRecovery) {
if (isPlanningCategory(t.category) && t.status === 'pending' && completedPlanningNames.has(`${t.category}:${t.phaseId}`)) {
await this.taskRepository.update(t.id, { status: 'completed', summary: 'Superseded by retry' });
tasksRecovered++;
log.info({ taskId: t.id, category: t.category }, 'recovered stale duplicate planning task');
}
}
// 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');

View File

@@ -46,8 +46,17 @@ export class SimpleGitBranchManager implements BranchManager {
return;
}
await git.branch([branch, baseBranch]);
log.info({ repoPath, branch, baseBranch }, 'branch created');
try {
await git.branch([branch, baseBranch]);
log.info({ repoPath, branch, baseBranch }, 'branch created');
} catch (err) {
// Handle TOCTOU race: branch may have been created between the exists check and now
if (err instanceof Error && err.message.includes('already exists')) {
log.debug({ repoPath, branch }, 'branch created by concurrent process');
return;
}
throw err;
}
}
async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> {

View File

@@ -0,0 +1,131 @@
/**
* Tests for the backfill-metrics script.
*
* Uses an in-memory test database to verify that backfillMetrics correctly
* accumulates counts from agent_log_chunks and upserts into agent_metrics.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js';
import type { DrizzleDatabase } from '../db/index.js';
import { agentLogChunks, agentMetrics } from '../db/index.js';
import { backfillMetrics } from './backfill-metrics.js';
import { nanoid } from 'nanoid';
import { eq } from 'drizzle-orm';
async function insertChunk(db: DrizzleDatabase, agentId: string, content: object | string) {
await db.insert(agentLogChunks).values({
id: nanoid(),
agentId,
agentName: 'test-agent',
sessionNumber: 1,
content: typeof content === 'string' ? content : JSON.stringify(content),
createdAt: new Date(),
});
}
describe('backfillMetrics', () => {
let db: DrizzleDatabase;
beforeEach(() => {
db = createTestDatabase();
});
it('AskUserQuestion chunks — questionsCount correct', async () => {
await insertChunk(db, 'agent-a', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}] } });
await insertChunk(db, 'agent-a', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}] } });
await backfillMetrics(db);
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-a'));
expect(rows).toHaveLength(1);
expect(rows[0].questionsCount).toBe(3);
expect(rows[0].subagentsCount).toBe(0);
expect(rows[0].compactionsCount).toBe(0);
});
it('Agent tool chunks — subagentsCount correct', async () => {
await insertChunk(db, 'agent-b', { type: 'tool_use', name: 'Agent' });
await insertChunk(db, 'agent-b', { type: 'tool_use', name: 'Agent' });
await backfillMetrics(db);
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-b'));
expect(rows).toHaveLength(1);
expect(rows[0].questionsCount).toBe(0);
expect(rows[0].subagentsCount).toBe(2);
expect(rows[0].compactionsCount).toBe(0);
});
it('Compaction chunks — compactionsCount correct', async () => {
await insertChunk(db, 'agent-c', { type: 'system', subtype: 'init', source: 'compact' });
await backfillMetrics(db);
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-c'));
expect(rows).toHaveLength(1);
expect(rows[0].questionsCount).toBe(0);
expect(rows[0].subagentsCount).toBe(0);
expect(rows[0].compactionsCount).toBe(1);
});
it('Irrelevant chunk type — no metrics row created', async () => {
await insertChunk(db, 'agent-d', { type: 'text', text: 'hello' });
await backfillMetrics(db);
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-d'));
expect(rows).toEqual([]);
});
it('Malformed JSON chunk — skipped, no crash', async () => {
await insertChunk(db, 'agent-e', 'not-valid-json');
await insertChunk(db, 'agent-e', { type: 'tool_use', name: 'Agent' });
await expect(backfillMetrics(db)).resolves.not.toThrow();
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-e'));
expect(rows).toHaveLength(1);
expect(rows[0].subagentsCount).toBe(1);
});
it('Multiple agents — counts isolated per agent', async () => {
await insertChunk(db, 'agent-f', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}, {}] } });
await insertChunk(db, 'agent-f', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}, {}] } });
await insertChunk(db, 'agent-g', { type: 'tool_use', name: 'Agent' });
await backfillMetrics(db);
const rowsF = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-f'));
expect(rowsF).toHaveLength(1);
expect(rowsF[0].questionsCount).toBe(6);
expect(rowsF[0].subagentsCount).toBe(0);
expect(rowsF[0].compactionsCount).toBe(0);
const rowsG = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-g'));
expect(rowsG).toHaveLength(1);
expect(rowsG[0].questionsCount).toBe(0);
expect(rowsG[0].subagentsCount).toBe(1);
expect(rowsG[0].compactionsCount).toBe(0);
});
it('Empty database — completes without error', async () => {
await expect(backfillMetrics(db)).resolves.not.toThrow();
const rows = await db.select().from(agentMetrics);
expect(rows).toEqual([]);
});
it('Re-run idempotency note — second run doubles counts', async () => {
// Documented behavior: run only once against a fresh agent_metrics table
await insertChunk(db, 'agent-h', { type: 'tool_use', name: 'Agent' });
await backfillMetrics(db);
const rowsAfterFirst = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-h'));
expect(rowsAfterFirst[0].subagentsCount).toBe(1);
await backfillMetrics(db);
const rowsAfterSecond = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-h'));
expect(rowsAfterSecond[0].subagentsCount).toBe(2);
});
});

View File

@@ -0,0 +1,128 @@
/**
* Backfill script for agent_metrics table.
*
* Reads all existing agent_log_chunks rows and populates agent_metrics with
* accumulated counts of questions, subagent spawns, and compaction events.
*
* Intended to be run once per production database after applying the migration
* that introduces the agent_metrics table.
*
* Idempotency note: Uses ON CONFLICT DO UPDATE with additive increments to match
* the ongoing insertChunk write-path behavior. Running against an empty
* agent_metrics table is fully safe. Running a second time will double-count —
* only run this script once per database, immediately after applying the migration.
*/
import { asc, sql } from 'drizzle-orm';
import { createDatabase, DrizzleDatabase, agentLogChunks, agentMetrics } from '../db/index.js';
const BATCH_SIZE = 500;
const LOG_EVERY = 1000;
/**
* Core backfill function. Accepts a DrizzleDatabase for testability.
*/
export async function backfillMetrics(db: DrizzleDatabase): Promise<void> {
const accumulator = new Map<string, { questionsCount: number; subagentsCount: number; compactionsCount: number }>();
let offset = 0;
let totalChunks = 0;
let malformedCount = 0;
while (true) {
const batch = await db
.select({ agentId: agentLogChunks.agentId, content: agentLogChunks.content })
.from(agentLogChunks)
.orderBy(asc(agentLogChunks.createdAt))
.limit(BATCH_SIZE)
.offset(offset);
if (batch.length === 0) break;
for (const chunk of batch) {
let parsed: unknown;
try {
parsed = JSON.parse(chunk.content);
} catch {
malformedCount++;
totalChunks++;
if (totalChunks % LOG_EVERY === 0) {
console.log(`Processed ${totalChunks} chunks...`);
}
continue;
}
if (typeof parsed !== 'object' || parsed === null) {
totalChunks++;
if (totalChunks % LOG_EVERY === 0) {
console.log(`Processed ${totalChunks} chunks...`);
}
continue;
}
const obj = parsed as Record<string, unknown>;
const type = obj['type'];
const name = obj['name'];
if (type === 'tool_use' && name === 'AskUserQuestion') {
const input = obj['input'] as Record<string, unknown> | undefined;
const questions = input?.['questions'];
const count = Array.isArray(questions) ? questions.length : 0;
if (count > 0) {
const entry = accumulator.get(chunk.agentId) ?? { questionsCount: 0, subagentsCount: 0, compactionsCount: 0 };
entry.questionsCount += count;
accumulator.set(chunk.agentId, entry);
}
} else if (type === 'tool_use' && name === 'Agent') {
const entry = accumulator.get(chunk.agentId) ?? { questionsCount: 0, subagentsCount: 0, compactionsCount: 0 };
entry.subagentsCount += 1;
accumulator.set(chunk.agentId, entry);
} else if (type === 'system' && obj['subtype'] === 'init' && obj['source'] === 'compact') {
const entry = accumulator.get(chunk.agentId) ?? { questionsCount: 0, subagentsCount: 0, compactionsCount: 0 };
entry.compactionsCount += 1;
accumulator.set(chunk.agentId, entry);
}
totalChunks++;
if (totalChunks % LOG_EVERY === 0) {
console.log(`Processed ${totalChunks} chunks...`);
}
}
offset += BATCH_SIZE;
}
// Upsert accumulated counts into agent_metrics.
// Uses additive ON CONFLICT DO UPDATE to match the ongoing insertChunk behavior.
for (const [agentId, counts] of accumulator) {
await db
.insert(agentMetrics)
.values({
agentId,
questionsCount: counts.questionsCount,
subagentsCount: counts.subagentsCount,
compactionsCount: counts.compactionsCount,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: agentMetrics.agentId,
set: {
questionsCount: sql`${agentMetrics.questionsCount} + ${counts.questionsCount}`,
subagentsCount: sql`${agentMetrics.subagentsCount} + ${counts.subagentsCount}`,
compactionsCount: sql`${agentMetrics.compactionsCount} + ${counts.compactionsCount}`,
updatedAt: new Date(),
},
});
}
console.log(
`Backfill complete: ${accumulator.size} agents updated, ${totalChunks} chunks processed, ${malformedCount} malformed chunks skipped`
);
}
/**
* CLI wrapper — opens a database from a path, then delegates to backfillMetrics.
*/
export async function backfillMetricsFromPath(dbPath: string): Promise<void> {
const db = createDatabase(dbPath);
await backfillMetrics(db);
}

View File

@@ -22,6 +22,7 @@ import type { LogChunkRepository } from '../db/repositories/log-chunk-repository
import type { ConversationRepository } from '../db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
import type { ErrandRepository } from '../db/repositories/errand-repository.js';
import type { AccountCredentialManager } from '../agent/credentials/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js';
@@ -82,6 +83,8 @@ export interface TrpcAdapterOptions {
reviewCommentRepository?: ReviewCommentRepository;
/** Project sync manager for remote fetch/sync operations */
projectSyncManager?: ProjectSyncManager;
/** Errand repository for errand CRUD operations */
errandRepository?: ErrandRepository;
/** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string;
}
@@ -166,6 +169,7 @@ export function createTrpcHandler(options: TrpcAdapterOptions) {
chatSessionRepository: options.chatSessionRepository,
reviewCommentRepository: options.reviewCommentRepository,
projectSyncManager: options.projectSyncManager,
errandRepository: options.errandRepository,
workspaceRoot: options.workspaceRoot,
}),
});

View File

@@ -326,6 +326,47 @@ describe('agent.listForRadar', () => {
expect(row!.subagentsCount).toBe(0);
expect(row!.compactionsCount).toBe(0);
});
it('returns zero counts for agent with no metrics row', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const now = new Date();
// Agent with no log chunks at all — no agent_metrics row will exist
agents.addAgent({ id: 'agent-no-chunks', name: 'no-chunks-agent', status: 'running', createdAt: now });
const caller = createAgentCaller(ctx);
const result = await caller.listForRadar({ timeRange: 'all' });
const row = result.find(r => r.id === 'agent-no-chunks');
expect(row).toBeDefined();
expect(row!.questionsCount).toBe(0);
expect(row!.subagentsCount).toBe(0);
expect(row!.compactionsCount).toBe(0);
});
it('listForRadar response does not contain chunk content field', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const { logChunkRepo } = getRepos(ctx);
const now = new Date();
agents.addAgent({ id: 'agent-content', name: 'content-agent', status: 'running', createdAt: now });
await logChunkRepo.insertChunk({
agentId: 'agent-content',
agentName: 'content-agent',
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' });
for (const row of result) {
expect(row).not.toHaveProperty('content');
}
});
});
// =============================================================================

View File

@@ -475,8 +475,8 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
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),
const [metrics, messageCounts, taskResults, initiativeResults] = await Promise.all([
logChunkRepo.findMetricsByAgentIds(matchingIds),
conversationRepo.countByFromAgentIds(matchingIds),
Promise.all(uniqueTaskIds.map(id => taskRepo.findById(id))),
Promise.all(uniqueInitiativeIds.map(id => initiativeRepo.findById(id))),
@@ -486,37 +486,14 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
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]);
}
}
const metricsMap = new Map(metrics.map(m => [m.agentId, m]));
// 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 */ }
}
const agentMetrics = metricsMap.get(agent.id);
const questionsCount = agentMetrics?.questionsCount ?? 0;
const subagentsCount = agentMetrics?.subagentsCount ?? 0;
const compactionsCount = agentMetrics?.compactionsCount ?? 0;
return {
id: agent.id,

View File

@@ -337,6 +337,14 @@ export function architectProcedures(publicProcedure: ProcedureBuilder) {
});
}
// Clean up orphaned pending/in_progress detail tasks from previous failed attempts
const phaseTasks = await taskRepo.findByPhaseId(input.phaseId);
for (const t of phaseTasks) {
if (t.category === 'detail' && (t.status === 'pending' || t.status === 'in_progress')) {
await taskRepo.update(t.id, { status: 'completed', summary: 'Superseded by retry' });
}
}
const detailTaskName = input.taskName ?? `Detail: ${phase.name}`;
const task = await taskRepo.create({
phaseId: phase.id,

View File

@@ -184,11 +184,11 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
.input(z.object({
projectId: z.string().optional(),
status: z.enum(ErrandStatusValues).optional(),
}))
}).optional())
.query(async ({ ctx, input }) => {
return requireErrandRepository(ctx).findAll({
projectId: input.projectId,
status: input.status,
projectId: input?.projectId,
status: input?.status,
});
}),

View File

@@ -4,10 +4,35 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { Task } from '../../db/schema.js';
import type { Phase, Task } from '../../db/schema.js';
import type { ProcedureBuilder } from '../trpc.js';
import { requirePhaseDispatchManager, requirePhaseRepository, requireTaskRepository } from './_helpers.js';
const INTEGRATION_PHASE_NAME = 'Integration';
const INTEGRATION_TASK_DESCRIPTION = `Verify that all phase branches integrate correctly after merging into the initiative branch.
Steps:
1. Build the project — fix any compilation errors
2. Run the full test suite — fix any failing tests
3. Run type checking and linting — fix any errors
4. Review cross-phase imports and shared interfaces for compatibility
5. Smoke test key user flows affected by the merged changes
Only fix integration issues (type mismatches, conflicting exports, broken tests). Do not refactor or improve existing code.`;
/**
* Find phase IDs that have no dependents (no other phase depends on them).
* These are the "end" / "leaf" phases in the dependency graph.
*/
function findEndPhaseIds(
phases: Phase[],
edges: Array<{ phaseId: string; dependsOnPhaseId: string }>,
): string[] {
const dependedOn = new Set(edges.map((e) => e.dependsOnPhaseId));
return phases.filter((p) => !dependedOn.has(p.id)).map((p) => p.id);
}
export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
return {
queuePhase: publicProcedure
@@ -23,7 +48,40 @@ export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
.mutation(async ({ ctx, input }) => {
const phaseDispatchManager = requirePhaseDispatchManager(ctx);
const phaseRepo = requirePhaseRepository(ctx);
const phases = await phaseRepo.findByInitiativeId(input.initiativeId);
const taskRepo = requireTaskRepository(ctx);
let phases = await phaseRepo.findByInitiativeId(input.initiativeId);
const edges = await phaseRepo.findDependenciesByInitiativeId(input.initiativeId);
// Auto-create Integration phase if multiple end phases exist
const existingIntegration = phases.find((p) => p.name === INTEGRATION_PHASE_NAME);
if (!existingIntegration) {
const endPhaseIds = findEndPhaseIds(phases, edges);
if (endPhaseIds.length > 1) {
const integrationPhase = await phaseRepo.create({
initiativeId: input.initiativeId,
name: INTEGRATION_PHASE_NAME,
status: 'approved',
});
for (const endPhaseId of endPhaseIds) {
await phaseRepo.createDependency(integrationPhase.id, endPhaseId);
}
await taskRepo.create({
phaseId: integrationPhase.id,
initiativeId: input.initiativeId,
name: 'Verify integration',
description: INTEGRATION_TASK_DESCRIPTION,
category: 'verify',
status: 'pending',
});
// Re-fetch so the new phase gets queued in the loop below
phases = await phaseRepo.findByInitiativeId(input.initiativeId);
}
}
let queued = 0;
for (const phase of phases) {
if (phase.status === 'approved') {

View File

@@ -331,8 +331,8 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps)
{/* Info line */}
<div className="px-5 pt-4 text-sm text-muted-foreground">
{errand.status === 'merged'
? `Merged into ${errand.baseBranch} · ${formatRelativeTime(errand.updatedAt.toISOString())}`
: `Abandoned · ${formatRelativeTime(errand.updatedAt.toISOString())}`}
? `Merged into ${errand.baseBranch} · ${formatRelativeTime(String(errand.updatedAt))}`
: `Abandoned · ${formatRelativeTime(String(errand.updatedAt))}`}
</div>
{/* Read-only diff */}

View File

@@ -22,6 +22,8 @@ interface RefineSpawnDialogProps {
showInstructionInput?: boolean;
/** Placeholder text for the instruction textarea */
instructionPlaceholder?: string;
/** Pre-populate the instruction field (e.g. from a crashed agent's original instruction) */
defaultInstruction?: string;
/** Whether the spawn mutation is pending */
isSpawning: boolean;
/** Error message if spawn failed */
@@ -38,6 +40,7 @@ export function RefineSpawnDialog({
description,
showInstructionInput = true,
instructionPlaceholder = "What should the agent focus on? (optional)",
defaultInstruction,
isSpawning,
error,
onSpawn,
@@ -53,18 +56,25 @@ export function RefineSpawnDialog({
onSpawn(finalInstruction);
};
const openDialog = () => {
setInstruction(defaultInstruction ?? "");
setShowDialog(true);
};
const handleOpenChange = (open: boolean) => {
setShowDialog(open);
if (!open) {
if (open) {
setInstruction(defaultInstruction ?? "");
} else {
setInstruction("");
}
setShowDialog(open);
};
const defaultTrigger = (
<Button
variant="outline"
size="sm"
onClick={() => setShowDialog(true)}
onClick={openDialog}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
@@ -75,7 +85,7 @@ export function RefineSpawnDialog({
return (
<>
{trigger ? (
<div onClick={() => setShowDialog(true)}>
<div onClick={openDialog}>
{trigger}
</div>
) : (

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { QuestionForm } from "@/components/QuestionForm";
@@ -6,6 +6,12 @@ import { ChangeSetBanner } from "@/components/ChangeSetBanner";
import { RefineSpawnDialog } from "../RefineSpawnDialog";
import { useRefineAgent } from "@/hooks";
function extractInstruction(prompt: string | null | undefined): string | undefined {
if (!prompt) return undefined;
const match = prompt.match(/<user_instruction>\n([\s\S]*?)\n<\/user_instruction>/);
return match?.[1] || undefined;
}
interface RefineAgentPanelProps {
initiativeId: string;
}
@@ -123,6 +129,11 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
}
// Crashed
const crashedInstruction = useMemo(
() => (state === "crashed" ? extractInstruction(agent?.prompt) : undefined),
[state, agent?.prompt],
);
if (state === "crashed") {
return (
<div className="mb-3 rounded-lg border border-destructive/50 bg-destructive/5 px-3 py-2">
@@ -134,6 +145,7 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
title="Refine Initiative Content"
description="An agent will review all pages and suggest improvements."
instructionPlaceholder="What should the agent focus on? (optional)"
defaultInstruction={crashedInstruction}
isSpawning={spawn.isPending}
error={spawn.error?.message}
onSpawn={handleSpawn}

View File

@@ -1,65 +0,0 @@
import { useNavigate } from '@tanstack/react-router'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from '@/components/ui/tooltip'
import { formatRelativeTime } from '@/lib/utils'
import type { WaitingForInputItem } from './types'
interface Props {
items: WaitingForInputItem[]
}
export function HQWaitingForInputSection({ items }: Props) {
const navigate = useNavigate()
return (
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Waiting for Input
</h2>
<div className="space-y-2">
{items.map((item) => {
const truncated =
item.questionText.slice(0, 120) +
(item.questionText.length > 120 ? '…' : '')
return (
<Card key={item.agentId} className="p-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="font-bold text-sm">
{item.agentName}
{item.initiativeName && (
<span className="font-normal text-muted-foreground"> · {item.initiativeName}</span>
)}
</p>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-sm text-muted-foreground truncate">{truncated}</p>
</TooltipTrigger>
<TooltipContent forceMount>{item.questionText}</TooltipContent>
</Tooltip>
</TooltipProvider>
<p className="text-xs text-muted-foreground">
waiting {formatRelativeTime(item.waitingSince)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: '/inbox' })}
>
Answer
</Button>
</Card>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import { render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
import { AppLayout } from './AppLayout'
// Mock dependencies
vi.mock('@tanstack/react-router', () => ({
Link: ({ children, to }: any) => {
const content = typeof children === 'function' ? children({ isActive: false }) : children
return <a href={to}>{content}</a>
},
}))
vi.mock('@/components/ThemeToggle', () => ({ ThemeToggle: () => null }))
vi.mock('@/components/HealthDot', () => ({ HealthDot: () => null }))
vi.mock('@/components/NavBadge', () => ({
NavBadge: ({ count }: { count: number }) => (
count > 0 ? <span data-testid="nav-badge">{count}</span> : null
),
}))
const mockUseQuery = vi.hoisted(() => vi.fn())
vi.mock('@/lib/trpc', () => ({
trpc: {
listAgents: { useQuery: mockUseQuery },
},
}))
beforeEach(() => {
vi.clearAllMocks()
mockUseQuery.mockReturnValue({ data: [] })
})
describe('AppLayout navItems', () => {
it('renders HQ nav link', () => {
render(<AppLayout connectionState="connected">{null}</AppLayout>)
expect(screen.getByRole('link', { name: /hq/i })).toBeInTheDocument()
})
it('does not render Inbox nav link', () => {
render(<AppLayout connectionState="connected">{null}</AppLayout>)
expect(screen.queryByRole('link', { name: /inbox/i })).not.toBeInTheDocument()
})
it('shows badge on HQ when agents are waiting_for_input', () => {
mockUseQuery.mockReturnValue({
data: [
{ id: '1', status: 'waiting_for_input' },
{ id: '2', status: 'running' },
],
})
render(<AppLayout connectionState="connected">{null}</AppLayout>)
// NavBadge rendered next to HQ link (count=1)
const hqLink = screen.getByRole('link', { name: /hq/i })
const badge = within(hqLink).getByTestId('nav-badge')
expect(badge).toHaveTextContent('1')
})
it('does not show questions badge on any Inbox link (Inbox removed)', () => {
mockUseQuery.mockReturnValue({
data: [{ id: '1', status: 'waiting_for_input' }],
})
render(<AppLayout connectionState="connected">{null}</AppLayout>)
expect(screen.queryByRole('link', { name: /inbox/i })).not.toBeInTheDocument()
})
})

View File

@@ -7,11 +7,11 @@ import { trpc } from '@/lib/trpc'
import type { ConnectionState } from '@/hooks/useConnectionStatus'
const navItems = [
{ label: 'HQ', to: '/hq', badgeKey: null },
{ label: 'HQ', to: '/hq', badgeKey: 'questions' as const },
{ label: 'Initiatives', to: '/initiatives', badgeKey: null },
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const },
{ label: 'Errands', to: '/errands', badgeKey: null },
{ label: 'Radar', to: '/radar', badgeKey: null },
{ label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const },
{ label: 'Settings', to: '/settings', badgeKey: null },
] as const

View File

@@ -17,6 +17,7 @@ import { Route as AgentsRouteImport } from './routes/agents'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
import { Route as InitiativesIndexRouteImport } from './routes/initiatives/index'
import { Route as ErrandsIndexRouteImport } from './routes/errands/index'
import { Route as SettingsProjectsRouteImport } from './routes/settings/projects'
import { Route as SettingsHealthRouteImport } from './routes/settings/health'
import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id'
@@ -61,6 +62,11 @@ const InitiativesIndexRoute = InitiativesIndexRouteImport.update({
path: '/initiatives/',
getParentRoute: () => rootRouteImport,
} as any)
const ErrandsIndexRoute = ErrandsIndexRouteImport.update({
id: '/errands/',
path: '/errands/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
id: '/projects',
path: '/projects',
@@ -87,6 +93,7 @@ export interface FileRoutesByFullPath {
'/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
'/settings/projects': typeof SettingsProjectsRoute
'/errands/': typeof ErrandsIndexRoute
'/initiatives/': typeof InitiativesIndexRoute
'/settings/': typeof SettingsIndexRoute
}
@@ -99,6 +106,7 @@ export interface FileRoutesByTo {
'/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
'/settings/projects': typeof SettingsProjectsRoute
'/errands': typeof ErrandsIndexRoute
'/initiatives': typeof InitiativesIndexRoute
'/settings': typeof SettingsIndexRoute
}
@@ -113,6 +121,7 @@ export interface FileRoutesById {
'/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
'/settings/projects': typeof SettingsProjectsRoute
'/errands/': typeof ErrandsIndexRoute
'/initiatives/': typeof InitiativesIndexRoute
'/settings/': typeof SettingsIndexRoute
}
@@ -128,6 +137,7 @@ export interface FileRouteTypes {
| '/initiatives/$id'
| '/settings/health'
| '/settings/projects'
| '/errands/'
| '/initiatives/'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
@@ -140,6 +150,7 @@ export interface FileRouteTypes {
| '/initiatives/$id'
| '/settings/health'
| '/settings/projects'
| '/errands'
| '/initiatives'
| '/settings'
id:
@@ -153,6 +164,7 @@ export interface FileRouteTypes {
| '/initiatives/$id'
| '/settings/health'
| '/settings/projects'
| '/errands/'
| '/initiatives/'
| '/settings/'
fileRoutesById: FileRoutesById
@@ -165,6 +177,7 @@ export interface RootRouteChildren {
RadarRoute: typeof RadarRoute
SettingsRoute: typeof SettingsRouteWithChildren
InitiativesIdRoute: typeof InitiativesIdRoute
ErrandsIndexRoute: typeof ErrandsIndexRoute
InitiativesIndexRoute: typeof InitiativesIndexRoute
}
@@ -226,6 +239,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof InitiativesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/errands/': {
id: '/errands/'
path: '/errands'
fullPath: '/errands/'
preLoaderRoute: typeof ErrandsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/projects': {
id: '/settings/projects'
path: '/projects'
@@ -274,6 +294,7 @@ const rootRouteChildren: RootRouteChildren = {
RadarRoute: RadarRoute,
SettingsRoute: SettingsRouteWithChildren,
InitiativesIdRoute: InitiativesIdRoute,
ErrandsIndexRoute: ErrandsIndexRoute,
InitiativesIndexRoute: InitiativesIndexRoute,
}
export const routeTree = rootRouteImport

View File

@@ -103,7 +103,7 @@ function ErrandsPage() {
{e.agentAlias ?? '—'}
</td>
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">
{formatRelativeTime(e.createdAt.toISOString())}
{formatRelativeTime(String(e.createdAt))}
</td>
</tr>
))}

View File

@@ -4,9 +4,24 @@ import { render, screen, fireEvent } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
const mockUseQuery = vi.hoisted(() => vi.fn())
const mockListWaitingAgentsQuery = vi.hoisted(() => vi.fn())
const mockListMessagesQuery = vi.hoisted(() => vi.fn())
const mockGetAgentQuestionsQuery = vi.hoisted(() => vi.fn())
const mockResumeAgentMutation = vi.hoisted(() => vi.fn())
const mockStopAgentMutation = vi.hoisted(() => vi.fn())
const mockRespondToMessageMutation = vi.hoisted(() => vi.fn())
const mockUseUtils = vi.hoisted(() => vi.fn())
vi.mock('@/lib/trpc', () => ({
trpc: {
getHeadquartersDashboard: { useQuery: mockUseQuery },
listWaitingAgents: { useQuery: mockListWaitingAgentsQuery },
listMessages: { useQuery: mockListMessagesQuery },
getAgentQuestions: { useQuery: mockGetAgentQuestionsQuery },
resumeAgent: { useMutation: mockResumeAgentMutation },
stopAgent: { useMutation: mockStopAgentMutation },
respondToMessage: { useMutation: mockRespondToMessageMutation },
useUtils: mockUseUtils,
},
}))
@@ -15,8 +30,33 @@ vi.mock('@/hooks', () => ({
LiveUpdateRule: undefined,
}))
vi.mock('@/components/hq/HQWaitingForInputSection', () => ({
HQWaitingForInputSection: ({ items }: any) => <div data-testid="waiting">{items.length}</div>,
vi.mock('@/components/InboxList', () => ({
InboxList: ({ agents, selectedAgentId, onSelectAgent }: any) => (
<div data-testid="inbox-list">
{agents.map((a: any) => (
<button
key={a.id}
data-testid={`agent-${a.id}`}
aria-selected={selectedAgentId === a.id}
onClick={() => onSelectAgent(a.id)}
>
{a.name}
</button>
))}
</div>
),
}))
vi.mock('@/components/InboxDetailPanel', () => ({
InboxDetailPanel: ({ agent, onSubmitAnswers, onDismissQuestions, onDismissMessage, onBack }: any) => (
<div data-testid="inbox-detail">
<span data-testid="selected-agent-name">{agent.name}</span>
<button onClick={() => onSubmitAnswers({ q1: 'answer' })}>Submit</button>
<button onClick={onDismissQuestions}>Dismiss Questions</button>
<button onClick={onDismissMessage}>Dismiss Message</button>
<button onClick={onBack}>Back</button>
</div>
),
}))
vi.mock('@/components/hq/HQNeedsReviewSection', () => ({
@@ -56,6 +96,16 @@ const emptyData = {
describe('HeadquartersPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListWaitingAgentsQuery.mockReturnValue({ data: [], isLoading: false })
mockListMessagesQuery.mockReturnValue({ data: [], isLoading: false })
mockGetAgentQuestionsQuery.mockReturnValue({ data: null, isLoading: false, isError: false })
mockResumeAgentMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false })
mockStopAgentMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false })
mockRespondToMessageMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false })
mockUseUtils.mockReturnValue({
listWaitingAgents: { invalidate: vi.fn() },
listMessages: { invalidate: vi.fn() },
})
})
it('renders skeleton loading state', () => {
@@ -68,7 +118,7 @@ describe('HeadquartersPage', () => {
const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]')
expect(skeletons.length).toBeGreaterThan(0)
// No section components
expect(screen.queryByTestId('waiting')).not.toBeInTheDocument()
expect(screen.queryByTestId('inbox-list')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
@@ -93,24 +143,25 @@ describe('HeadquartersPage', () => {
render(<HeadquartersPage />)
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
expect(screen.queryByTestId('waiting')).not.toBeInTheDocument()
expect(screen.queryByTestId('inbox-list')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
})
it('renders WaitingForInput section when items exist', () => {
it('renders InboxList when waitingForInput items exist', () => {
mockUseQuery.mockReturnValue({
isLoading: false,
isError: false,
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
})
mockListWaitingAgentsQuery.mockReturnValue({
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
isLoading: false,
})
render(<HeadquartersPage />)
expect(screen.getByTestId('waiting')).toBeInTheDocument()
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
expect(screen.getByTestId('inbox-list')).toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
@@ -127,9 +178,13 @@ describe('HeadquartersPage', () => {
blockedPhases: [{ id: '6' }],
},
})
mockListWaitingAgentsQuery.mockReturnValue({
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
isLoading: false,
})
render(<HeadquartersPage />)
expect(screen.getByTestId('waiting')).toBeInTheDocument()
expect(screen.getByTestId('inbox-list')).toBeInTheDocument()
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
expect(screen.getByTestId('needs-approval')).toBeInTheDocument()
expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument()
@@ -160,4 +215,41 @@ describe('HeadquartersPage', () => {
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
it('shows InboxDetailPanel when an agent is selected from InboxList', async () => {
mockUseQuery.mockReturnValue({
isLoading: false,
isError: false,
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
})
mockListWaitingAgentsQuery.mockReturnValue({
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
isLoading: false,
})
render(<HeadquartersPage />)
fireEvent.click(screen.getByTestId('agent-a1'))
expect(await screen.findByTestId('inbox-detail')).toBeInTheDocument()
expect(screen.getByTestId('selected-agent-name')).toHaveTextContent('Agent 1')
})
it('clears selection when Back is clicked in InboxDetailPanel', async () => {
mockUseQuery.mockReturnValue({
isLoading: false,
isError: false,
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
})
mockListWaitingAgentsQuery.mockReturnValue({
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
isLoading: false,
})
render(<HeadquartersPage />)
fireEvent.click(screen.getByTestId('agent-a1'))
expect(screen.getByTestId('inbox-detail')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /back/i }))
expect(screen.queryByTestId('inbox-detail')).not.toBeInTheDocument()
})
})

View File

@@ -1,10 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { motion } from "motion/react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { useLiveUpdates, type LiveUpdateRule } from "@/hooks";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection";
import { InboxList } from "@/components/InboxList";
import { InboxDetailPanel } from "@/components/InboxDetailPanel";
import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection";
import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection";
import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection";
@@ -19,12 +22,108 @@ const HQ_LIVE_UPDATE_RULES: LiveUpdateRule[] = [
{ prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] },
{ prefix: "phase:", invalidate: ["getHeadquartersDashboard"] },
{ prefix: "agent:", invalidate: ["getHeadquartersDashboard"] },
{ prefix: "agent:", invalidate: ["listWaitingAgents", "listMessages"] },
];
export function HeadquartersPage() {
useLiveUpdates(HQ_LIVE_UPDATE_RULES);
const query = trpc.getHeadquartersDashboard.useQuery();
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const utils = trpc.useUtils();
const agentsQuery = trpc.listWaitingAgents.useQuery();
const messagesQuery = trpc.listMessages.useQuery({});
const questionsQuery = trpc.getAgentQuestions.useQuery(
{ id: selectedAgentId! },
{ enabled: !!selectedAgentId }
);
const resumeAgentMutation = trpc.resumeAgent.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Answer submitted");
},
onError: () => {
toast.error("Failed to submit answer");
},
});
const dismissQuestionsMutation = trpc.stopAgent.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Questions dismissed");
},
onError: () => {
toast.error("Failed to dismiss questions");
},
});
const respondToMessageMutation = trpc.respondToMessage.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Response sent");
},
onError: () => {
toast.error("Failed to send response");
},
});
const agents = agentsQuery.data ?? [];
const messages = messagesQuery.data ?? [];
const selectedAgent = selectedAgentId
? agents.find((a) => a.id === selectedAgentId) ?? null
: null;
const selectedMessage = selectedAgentId
? messages
.filter((m) => m.senderId === selectedAgentId)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)[0] ?? null
: null;
const pendingQuestions = questionsQuery.data ?? null;
function handleRefresh() {
void utils.listWaitingAgents.invalidate();
void utils.listMessages.invalidate();
}
function handleSubmitAnswers(answers: Record<string, string>) {
if (!selectedAgentId) return;
resumeAgentMutation.mutate({ id: selectedAgentId, answers });
}
function handleDismissQuestions() {
if (!selectedAgentId) return;
dismissQuestionsMutation.mutate({ id: selectedAgentId });
}
function handleDismiss() {
if (!selectedMessage) return;
respondToMessageMutation.mutate({
id: selectedMessage.id,
response: "Acknowledged",
});
}
const serializedAgents = agents.map((a) => ({
id: a.id,
name: a.name,
status: a.status,
taskId: a.taskId ?? "",
updatedAt: String(a.updatedAt),
}));
const serializedMessages = messages.map((m) => ({
id: m.id,
senderId: m.senderId,
content: m.content,
requiresResponse: m.requiresResponse,
status: m.status,
createdAt: String(m.createdAt),
}));
if (query.isLoading) {
return (
<motion.div
@@ -97,7 +196,80 @@ export function HeadquartersPage() {
) : (
<div className="space-y-8">
{data.waitingForInput.length > 0 && (
<HQWaitingForInputSection items={data.waitingForInput} />
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Waiting for Input
</h2>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
{/* Left: agent list — hidden on mobile when an agent is selected */}
<div className={selectedAgent ? "hidden lg:block" : undefined}>
<InboxList
agents={serializedAgents}
messages={serializedMessages}
selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
onRefresh={handleRefresh}
/>
</div>
{/* Right: detail panel */}
{selectedAgent ? (
<InboxDetailPanel
agent={{
id: selectedAgent.id,
name: selectedAgent.name,
status: selectedAgent.status,
taskId: selectedAgent.taskId ?? null,
updatedAt: String(selectedAgent.updatedAt),
}}
message={
selectedMessage
? {
id: selectedMessage.id,
content: selectedMessage.content,
requiresResponse: selectedMessage.requiresResponse,
}
: null
}
questions={
pendingQuestions
? pendingQuestions.questions.map((q) => ({
id: q.id,
question: q.question,
options: q.options,
multiSelect: q.multiSelect,
}))
: null
}
isLoadingQuestions={questionsQuery.isLoading}
questionsError={
questionsQuery.isError ? questionsQuery.error.message : null
}
onBack={() => setSelectedAgentId(null)}
onSubmitAnswers={handleSubmitAnswers}
onDismissQuestions={handleDismissQuestions}
onDismissMessage={handleDismiss}
isSubmitting={resumeAgentMutation.isPending}
isDismissingQuestions={dismissQuestionsMutation.isPending}
isDismissingMessage={respondToMessageMutation.isPending}
submitError={
resumeAgentMutation.isError
? resumeAgentMutation.error.message
: null
}
dismissMessageError={
respondToMessageMutation.isError
? respondToMessageMutation.error.message
: null
}
/>
) : (
<div className="hidden flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border p-8 lg:flex">
<p className="text-sm font-medium text-muted-foreground">No agent selected</p>
<p className="text-xs text-muted-foreground/70">Select an agent from the list to answer their questions</p>
</div>
)}
</div>
</div>
)}
{(data.pendingReviewInitiatives.length > 0 ||
data.pendingReviewPhases.length > 0) && (

View File

@@ -1,269 +1,7 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle, MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/Skeleton";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { InboxList } from "@/components/InboxList";
import { InboxDetailPanel } from "@/components/InboxDetailPanel";
import { useLiveUpdates } from "@/hooks";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/inbox")({
component: InboxPage,
beforeLoad: () => {
throw redirect({ to: "/hq" });
},
});
function InboxPage() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
// Single SSE stream for live updates
useLiveUpdates([
{ prefix: 'agent:', invalidate: ['listWaitingAgents', 'listMessages'] },
]);
const utils = trpc.useUtils();
// Data fetching
const agentsQuery = trpc.listWaitingAgents.useQuery();
const messagesQuery = trpc.listMessages.useQuery({});
const questionsQuery = trpc.getAgentQuestions.useQuery(
{ id: selectedAgentId! },
{ enabled: !!selectedAgentId }
);
// Mutations
const resumeAgentMutation = trpc.resumeAgent.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Answer submitted");
},
onError: () => {
toast.error("Failed to submit answer");
},
});
const dismissQuestionsMutation = trpc.stopAgent.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Questions dismissed");
},
onError: () => {
toast.error("Failed to dismiss questions");
},
});
const respondToMessageMutation = trpc.respondToMessage.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Response sent");
},
onError: () => {
toast.error("Failed to send response");
},
});
// Find selected agent info
const agents = agentsQuery.data ?? [];
const messages = messagesQuery.data ?? [];
const selectedAgent = selectedAgentId
? agents.find((a) => a.id === selectedAgentId) ?? null
: null;
// Find the latest message for the selected agent
const selectedMessage = selectedAgentId
? messages
.filter((m) => m.senderId === selectedAgentId)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)[0] ?? null
: null;
const pendingQuestions = questionsQuery.data ?? null;
// Handlers
function handleRefresh() {
void utils.listWaitingAgents.invalidate();
void utils.listMessages.invalidate();
}
function handleSubmitAnswers(answers: Record<string, string>) {
if (!selectedAgentId) return;
resumeAgentMutation.mutate({ id: selectedAgentId, answers });
}
function handleDismissQuestions() {
if (!selectedAgentId) return;
dismissQuestionsMutation.mutate({ id: selectedAgentId });
}
function handleDismiss() {
if (!selectedMessage) return;
respondToMessageMutation.mutate({
id: selectedMessage.id,
response: "Acknowledged",
});
}
// Loading state
if (agentsQuery.isLoading && messagesQuery.isLoading) {
return (
<div className="space-y-4">
{/* Skeleton header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-28" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
<Skeleton className="h-8 w-20" />
</div>
{/* Skeleton message rows */}
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="mt-2 ml-5 h-3 w-full" />
</div>
<Skeleton className="h-3 w-16" />
</div>
</Card>
))}
</div>
</div>
);
}
// Error state
if (agentsQuery.isError || messagesQuery.isError) {
const errorMessage =
agentsQuery.error?.message ?? messagesQuery.error?.message ?? "Unknown error";
return (
<div className="flex flex-col items-center justify-center gap-4 py-12">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">
Failed to load inbox: {errorMessage}
</p>
<Button variant="outline" size="sm" onClick={handleRefresh}>
Retry
</Button>
</div>
);
}
// Serialize agents for InboxList (convert Date to string for wire format)
const serializedAgents = agents.map((a) => ({
id: a.id,
name: a.name,
status: a.status,
taskId: a.taskId,
updatedAt: String(a.updatedAt),
}));
// Serialize messages for InboxList
const serializedMessages = messages.map((m) => ({
id: m.id,
senderId: m.senderId,
content: m.content,
requiresResponse: m.requiresResponse,
status: m.status,
createdAt: String(m.createdAt),
}));
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
className="space-y-6"
>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
{/* Left: Inbox List -- hidden on mobile when agent selected */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
className={selectedAgent ? "hidden lg:block" : undefined}
>
<InboxList
agents={serializedAgents}
messages={serializedMessages}
selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
onRefresh={handleRefresh}
/>
</motion.div>
{/* Right: Detail Panel */}
{selectedAgent && (
<InboxDetailPanel
agent={{
id: selectedAgent.id,
name: selectedAgent.name,
status: selectedAgent.status,
taskId: selectedAgent.taskId,
updatedAt: String(selectedAgent.updatedAt),
}}
message={
selectedMessage
? {
id: selectedMessage.id,
content: selectedMessage.content,
requiresResponse: selectedMessage.requiresResponse,
}
: null
}
questions={
pendingQuestions
? pendingQuestions.questions.map((q) => ({
id: q.id,
question: q.question,
options: q.options,
multiSelect: q.multiSelect,
}))
: null
}
isLoadingQuestions={questionsQuery.isLoading}
questionsError={
questionsQuery.isError ? questionsQuery.error.message : null
}
onBack={() => setSelectedAgentId(null)}
onSubmitAnswers={handleSubmitAnswers}
onDismissQuestions={handleDismissQuestions}
onDismissMessage={handleDismiss}
isSubmitting={resumeAgentMutation.isPending}
isDismissingQuestions={dismissQuestionsMutation.isPending}
isDismissingMessage={respondToMessageMutation.isPending}
submitError={
resumeAgentMutation.isError
? resumeAgentMutation.error.message
: null
}
dismissMessageError={
respondToMessageMutation.isError
? respondToMessageMutation.error.message
: null
}
/>
)}
{/* Empty detail panel placeholder */}
{!selectedAgent && (
<div className="hidden flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border p-8 lg:flex">
<MessageSquare className="h-10 w-10 text-muted-foreground/30" />
<div className="space-y-1 text-center">
<p className="text-sm font-medium text-muted-foreground">No message selected</p>
<p className="text-xs text-muted-foreground/70">Select an agent from the inbox to view details</p>
</div>
</div>
)}
</div>
</motion.div>
);
}

File diff suppressed because one or more lines are too long