Compare commits
16 Commits
ed9184e0f1
...
0e5e68dfff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e5e68dfff | ||
|
|
43e53e7b6a | ||
|
|
ce991fd8a4 | ||
|
|
59e710bc31 | ||
|
|
57784576e4 | ||
|
|
4a657d6b96 | ||
|
|
bf4a55f2f2 | ||
|
|
89f74efdb5 | ||
|
|
3885a96c9d | ||
|
|
c3fb000f92 | ||
|
|
4298a8f4a6 | ||
|
|
1dc908a8ab | ||
|
|
1acc0b297e | ||
|
|
7c35f262cf | ||
|
|
61aa0f9dd4 | ||
|
|
d29d375bba |
@@ -133,7 +133,7 @@ describe('FileSystemSignalManager', () => {
|
|||||||
// Write signal after a delay
|
// Write signal after a delay
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await writeFile(signalPath, JSON.stringify(expectedSignal));
|
await writeFile(signalPath, JSON.stringify(expectedSignal));
|
||||||
}, 100);
|
}, 30);
|
||||||
|
|
||||||
const signal = await signalManager.waitForSignal(agentWorkdir, 1000);
|
const signal = await signalManager.waitForSignal(agentWorkdir, 1000);
|
||||||
expect(signal).toEqual(expectedSignal);
|
expect(signal).toEqual(expectedSignal);
|
||||||
|
|||||||
@@ -1,39 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Errand mode prompt — small, focused changes in an isolated worktree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DEVIATION_RULES, GIT_WORKFLOW } from './shared.js';
|
||||||
|
|
||||||
export function buildErrandPrompt(description: string): string {
|
export function buildErrandPrompt(description: string): string {
|
||||||
return `You are working on a small, focused change in an isolated worktree.
|
return `<role>
|
||||||
|
You are a Worker agent making a small, focused change in an isolated worktree.
|
||||||
|
Make only the changes needed to fulfill the description. Do not expand scope.
|
||||||
|
</role>
|
||||||
|
|
||||||
Description: ${description}
|
<task>
|
||||||
|
${description}
|
||||||
|
</task>
|
||||||
|
|
||||||
Work interactively with the user. Make only the changes needed to fulfill the description.
|
<session_startup>
|
||||||
Before signaling completion, commit all your changes with a clear commit message describing what you did. Do not leave uncommitted work.
|
1. \`pwd\` — confirm working directory
|
||||||
When you are done, write .cw/output/signal.json:
|
2. \`git status\` — check for unexpected state
|
||||||
|
3. Read \`CLAUDE.md\` at the repo root (if it exists) — it contains project conventions and patterns you must follow.
|
||||||
|
4. Read \`.cw/expected-pwd.txt\` — it contains the absolute path you should be working from. If your \`pwd\` doesn't match, \`cd\` to that path before doing anything else.
|
||||||
|
</session_startup>
|
||||||
|
|
||||||
{ "status": "done", "result": { "message": "<one-sentence summary of what you changed>" } }
|
<execution_rules>
|
||||||
|
1. Read any files relevant to the task before making changes.
|
||||||
|
2. Implement the change — minimum code to fulfill the description.
|
||||||
|
3. Run tests, linter, or type checker if the change touches testable code.
|
||||||
|
4. Stage specific files with \`git add <file>\`, commit with a clear message describing what you did.
|
||||||
|
5. Do not leave uncommitted work.
|
||||||
|
</execution_rules>
|
||||||
|
|
||||||
If you cannot complete the change:
|
<anti_patterns>
|
||||||
|
- **Scope creep**: Only do what the description asks. No drive-by refactors, no bonus features.
|
||||||
|
- **Blind edits**: Always read a file before modifying it.
|
||||||
|
- **Debug artifacts**: Remove all \`console.log\`, debug statements, and temporary instrumentation before committing.
|
||||||
|
- **Spinning on failures**: If a fix attempt fails 3 times, signal "error" with what you tried. Don't loop indefinitely.
|
||||||
|
- **Relative path assumptions**: Always use absolute paths or verify your working directory first.
|
||||||
|
</anti_patterns>
|
||||||
|
${DEVIATION_RULES}
|
||||||
|
${GIT_WORKFLOW}
|
||||||
|
|
||||||
{ "status": "error", "error": "<explanation>" }
|
<signal_format>
|
||||||
|
CRITICAL: Write \`.cw/output/signal.json\` as your ABSOLUTE LAST action. The system monitors this file as a completion trigger — writing it before committing causes your work to be silently discarded.
|
||||||
|
|
||||||
Do not create any other output files.`;
|
- Done: \`{ "status": "done", "result": { "message": "<one-sentence summary of what you changed>" } }\`
|
||||||
|
- Unrecoverable error: \`{ "status": "error", "error": "<explanation>" }\` — include the actual error output, stack trace, or repro steps, not just a summary
|
||||||
|
|
||||||
|
Do not create any other output files.
|
||||||
|
</signal_format>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildErrandRevisionPrompt(description: string, feedback: string): string {
|
export function buildErrandRevisionPrompt(description: string, feedback: string): string {
|
||||||
return `You are revising a previous change in an isolated worktree. The worktree already contains your prior work.
|
return `<role>
|
||||||
|
You are a Worker agent revising a previous change in an isolated worktree.
|
||||||
|
The worktree already contains your prior work. Address the feedback without undoing prior work unless specifically asked.
|
||||||
|
</role>
|
||||||
|
|
||||||
Original description: ${description}
|
<task>
|
||||||
|
**Original description:**
|
||||||
The user reviewed your changes and requested revisions:
|
${description}
|
||||||
|
|
||||||
|
**Revision feedback:**
|
||||||
${feedback}
|
${feedback}
|
||||||
|
</task>
|
||||||
|
|
||||||
Make only the changes needed to address the feedback. Do not undo prior work unless the feedback specifically asks for it.
|
<session_startup>
|
||||||
Before signaling completion, commit all your changes with a clear commit message describing what you did. Do not leave uncommitted work.
|
1. \`pwd\` — confirm working directory
|
||||||
When you are done, write .cw/output/signal.json:
|
2. \`git status\` — check for unexpected state
|
||||||
|
3. Read \`CLAUDE.md\` at the repo root (if it exists) — it contains project conventions and patterns you must follow.
|
||||||
|
4. Read \`.cw/expected-pwd.txt\` — it contains the absolute path you should be working from. If your \`pwd\` doesn't match, \`cd\` to that path before doing anything else.
|
||||||
|
</session_startup>
|
||||||
|
|
||||||
{ "status": "done", "result": { "message": "<one-sentence summary of what you changed>" } }
|
<execution_rules>
|
||||||
|
1. Read the files affected by your prior work and the feedback.
|
||||||
|
2. Implement only the changes needed to address the feedback.
|
||||||
|
3. Do not undo prior work unless the feedback specifically asks for it.
|
||||||
|
4. Run tests, linter, or type checker if the change touches testable code.
|
||||||
|
5. Stage specific files with \`git add <file>\`, commit with a clear message describing what you revised.
|
||||||
|
6. Do not leave uncommitted work.
|
||||||
|
</execution_rules>
|
||||||
|
|
||||||
If you cannot complete the change:
|
<anti_patterns>
|
||||||
|
- **Scope creep**: Only address the feedback. No drive-by refactors, no bonus features.
|
||||||
|
- **Blind edits**: Always read a file before modifying it.
|
||||||
|
- **Undoing prior work**: Your previous changes are intentional. Only revert what the feedback explicitly asks to change.
|
||||||
|
- **Debug artifacts**: Remove all \`console.log\`, debug statements, and temporary instrumentation before committing.
|
||||||
|
- **Spinning on failures**: If a fix attempt fails 3 times, signal "error" with what you tried. Don't loop indefinitely.
|
||||||
|
- **Relative path assumptions**: Always use absolute paths or verify your working directory first.
|
||||||
|
</anti_patterns>
|
||||||
|
${DEVIATION_RULES}
|
||||||
|
${GIT_WORKFLOW}
|
||||||
|
|
||||||
{ "status": "error", "error": "<explanation>" }
|
<signal_format>
|
||||||
|
CRITICAL: Write \`.cw/output/signal.json\` as your ABSOLUTE LAST action. The system monitors this file as a completion trigger — writing it before committing causes your work to be silently discarded.
|
||||||
|
|
||||||
Do not create any other output files.`;
|
- Done: \`{ "status": "done", "result": { "message": "<one-sentence summary of what you changed>" } }\`
|
||||||
|
- Unrecoverable error: \`{ "status": "error", "error": "<explanation>" }\` — include the actual error output, stack trace, or repro steps, not just a summary
|
||||||
|
|
||||||
|
Do not create any other output files.
|
||||||
|
</signal_format>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
export const SIGNAL_FORMAT = `
|
export const SIGNAL_FORMAT = `
|
||||||
<signal_format>
|
<signal_format>
|
||||||
As your final action, write \`.cw/output/signal.json\`:
|
CRITICAL: Write \`.cw/output/signal.json\` as your ABSOLUTE LAST action. The system monitors this file as a completion trigger — writing it before your output files causes your work to be silently discarded.
|
||||||
- Done: \`{ "status": "done" }\`
|
- Done: \`{ "status": "done" }\`
|
||||||
- Need clarification: \`{ "status": "questions", "questions": [{ "id": "q1", "question": "..." }] }\`
|
- Need clarification: \`{ "status": "questions", "questions": [{ "id": "q1", "question": "..." }] }\`
|
||||||
- Unrecoverable error: \`{ "status": "error", "error": "..." }\` — include the actual error output, stack trace, or repro steps, not just a summary
|
- Unrecoverable error: \`{ "status": "error", "error": "..." }\` — include the actual error output, stack trace, or repro steps, not just a summary
|
||||||
|
|||||||
@@ -353,7 +353,11 @@ export class ExecutionOrchestrator {
|
|||||||
if (initiative.executionMode === 'yolo') {
|
if (initiative.executionMode === 'yolo') {
|
||||||
// Merge phase branch into initiative branch (only when branches exist)
|
// Merge phase branch into initiative branch (only when branches exist)
|
||||||
if (initiative.branch) {
|
if (initiative.branch) {
|
||||||
await this.mergePhaseIntoInitiative(phaseId);
|
try {
|
||||||
|
await this.mergePhaseIntoInitiative(phaseId);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ phaseId, err: err instanceof Error ? err.message : String(err) }, 'phase merge failed, completing phase anyway');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await this.phaseDispatchManager.completePhase(phaseId);
|
await this.phaseDispatchManager.completePhase(phaseId);
|
||||||
|
|
||||||
@@ -663,6 +667,7 @@ export class ExecutionOrchestrator {
|
|||||||
const phases = await this.phaseRepository.findByInitiativeId(initiative.id);
|
const phases = await this.phaseRepository.findByInitiativeId(initiative.id);
|
||||||
|
|
||||||
for (const phase of phases) {
|
for (const phase of phases) {
|
||||||
|
try {
|
||||||
// Re-queue approved phases into the phase dispatch queue
|
// Re-queue approved phases into the phase dispatch queue
|
||||||
if (phase.status === 'approved') {
|
if (phase.status === 'approved') {
|
||||||
try {
|
try {
|
||||||
@@ -734,6 +739,9 @@ export class ExecutionOrchestrator {
|
|||||||
phasesRecovered++;
|
phasesRecovered++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ phaseId: phase.id, err: err instanceof Error ? err.message : String(err) }, 'phase recovery failed, continuing with remaining phases');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Uses temporary git repositories for each test.
|
* Uses temporary git repositories for each test.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||||
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
|
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -48,16 +48,34 @@ async function createTestRepo(): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('SimpleGitWorktreeManager', () => {
|
describe('SimpleGitWorktreeManager', () => {
|
||||||
|
let templatePath: string;
|
||||||
|
let templateCleanup: () => Promise<void>;
|
||||||
let repoPath: string;
|
let repoPath: string;
|
||||||
let cleanup: () => Promise<void>;
|
let cleanup: () => Promise<void>;
|
||||||
let manager: SimpleGitWorktreeManager;
|
let manager: SimpleGitWorktreeManager;
|
||||||
let eventBus: EventBus;
|
let eventBus: EventBus;
|
||||||
let emittedEvents: Array<{ type: string; payload: unknown }>;
|
let emittedEvents: Array<{ type: string; payload: unknown }>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeAll(async () => {
|
||||||
const testRepo = await createTestRepo();
|
const testRepo = await createTestRepo();
|
||||||
repoPath = testRepo.repoPath;
|
templatePath = testRepo.repoPath;
|
||||||
cleanup = testRepo.cleanup;
|
templateCleanup = testRepo.cleanup;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await templateCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clone the template repo locally (hard-links, ~10ms vs ~50ms full init)
|
||||||
|
repoPath = await mkdtemp(path.join(tmpdir(), 'cw-test-repo-'));
|
||||||
|
await rm(repoPath, { recursive: true, force: true });
|
||||||
|
const git = simpleGit();
|
||||||
|
await git.clone(templatePath, repoPath, ['--local']);
|
||||||
|
|
||||||
|
cleanup = async () => {
|
||||||
|
await rm(repoPath, { recursive: true, force: true });
|
||||||
|
};
|
||||||
|
|
||||||
// Create event bus and track emitted events
|
// Create event bus and track emitted events
|
||||||
eventBus = new EventEmitterBus();
|
eventBus = new EventEmitterBus();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* a project's default branch.
|
* a project's default branch.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -133,14 +133,14 @@ describe('SimpleGitBranchManager', () => {
|
|||||||
let cleanup: () => Promise<void>;
|
let cleanup: () => Promise<void>;
|
||||||
let branchManager: SimpleGitBranchManager;
|
let branchManager: SimpleGitBranchManager;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeAll(async () => {
|
||||||
const setup = await createTestRepoWithRemote();
|
const setup = await createTestRepoWithRemote();
|
||||||
clonePath = setup.clonePath;
|
clonePath = setup.clonePath;
|
||||||
cleanup = setup.cleanup;
|
cleanup = setup.cleanup;
|
||||||
branchManager = new SimpleGitBranchManager();
|
branchManager = new SimpleGitBranchManager();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterAll(async () => {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,14 +177,14 @@ describe('SimpleGitBranchManager - diffBranchesStat and diffFileSingle', () => {
|
|||||||
let cleanup: () => Promise<void>;
|
let cleanup: () => Promise<void>;
|
||||||
let branchManager: SimpleGitBranchManager;
|
let branchManager: SimpleGitBranchManager;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeAll(async () => {
|
||||||
const setup = await createTestRepoForDiff();
|
const setup = await createTestRepoForDiff();
|
||||||
clonePath = setup.clonePath;
|
clonePath = setup.clonePath;
|
||||||
cleanup = setup.cleanup;
|
cleanup = setup.cleanup;
|
||||||
branchManager = new SimpleGitBranchManager();
|
branchManager = new SimpleGitBranchManager();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterAll(async () => {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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
@@ -1,15 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Integration tests for initiative tRPC router — qualityReview field.
|
* Integration tests for initiative tRPC router — qualityReview field and listInitiatives sort.
|
||||||
*
|
*
|
||||||
* Verifies that updateInitiativeConfig accepts and persists qualityReview.
|
* Verifies that updateInitiativeConfig accepts and persists qualityReview,
|
||||||
|
* and that listInitiatives returns initiatives in priority-sorted order.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { router, publicProcedure, createCallerFactory } from '../trpc.js';
|
import { router, publicProcedure, createCallerFactory } from '../trpc.js';
|
||||||
import { initiativeProcedures } from './initiative.js';
|
import { initiativeProcedures } from './initiative.js';
|
||||||
import type { TRPCContext } from '../context.js';
|
import type { TRPCContext } from '../context.js';
|
||||||
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
|
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
|
||||||
import { DrizzleInitiativeRepository } from '../../db/repositories/drizzle/index.js';
|
import { DrizzleInitiativeRepository, DrizzlePhaseRepository } from '../../db/repositories/drizzle/index.js';
|
||||||
|
import { initiatives as initiativesTable } from '../../db/schema.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Mock ensureProjectClone — prevents actual git cloning
|
// Mock ensureProjectClone — prevents actual git cloning
|
||||||
@@ -61,6 +64,99 @@ async function setup() {
|
|||||||
// Tests
|
// Tests
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// listInitiatives sort order tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('listInitiatives — sort order', () => {
|
||||||
|
async function setupWithPhaseRepo() {
|
||||||
|
const db = createTestDatabase();
|
||||||
|
const initiativeRepo = new DrizzleInitiativeRepository(db);
|
||||||
|
const phaseRepo = new DrizzlePhaseRepository(db);
|
||||||
|
const ctx: TRPCContext = {
|
||||||
|
eventBus: createMockEventBus(),
|
||||||
|
serverStartedAt: null,
|
||||||
|
processCount: 0,
|
||||||
|
initiativeRepository: initiativeRepo,
|
||||||
|
phaseRepository: phaseRepo,
|
||||||
|
};
|
||||||
|
const caller = createCaller(ctx);
|
||||||
|
return { caller, initiativeRepo, phaseRepo, db };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupNoPhaseRepo() {
|
||||||
|
const db = createTestDatabase();
|
||||||
|
const initiativeRepo = new DrizzleInitiativeRepository(db);
|
||||||
|
const ctx: TRPCContext = {
|
||||||
|
eventBus: createMockEventBus(),
|
||||||
|
serverStartedAt: null,
|
||||||
|
processCount: 0,
|
||||||
|
initiativeRepository: initiativeRepo,
|
||||||
|
};
|
||||||
|
const caller = createCaller(ctx);
|
||||||
|
return { caller, initiativeRepo, db };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('executing appears before idle/planning (state priority 0 < 1)', async () => {
|
||||||
|
const { caller, initiativeRepo, phaseRepo } = await setupWithPhaseRepo();
|
||||||
|
const executingInit = await initiativeRepo.create({ name: 'Executing' });
|
||||||
|
const planningInit = await initiativeRepo.create({ name: 'Planning' });
|
||||||
|
await phaseRepo.create({ initiativeId: executingInit.id, name: 'Phase 1', status: 'in_progress' });
|
||||||
|
// planningInit has no phases → derives 'idle' (priority 1 via fallback)
|
||||||
|
// executingInit has in_progress phase → derives 'executing' (priority 0)
|
||||||
|
const result = await caller.listInitiatives({});
|
||||||
|
expect(result[0].id).toBe(executingInit.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ready appears before blocked (priority 1 < 2)', async () => {
|
||||||
|
const { caller, initiativeRepo, phaseRepo } = await setupWithPhaseRepo();
|
||||||
|
const blockedInit = await initiativeRepo.create({ name: 'Blocked' });
|
||||||
|
const readyInit = await initiativeRepo.create({ name: 'Ready' });
|
||||||
|
await phaseRepo.create({ initiativeId: blockedInit.id, name: 'Blocked Phase', status: 'blocked' });
|
||||||
|
await phaseRepo.create({ initiativeId: readyInit.id, name: 'Ready Phase', status: 'approved' });
|
||||||
|
const result = await caller.listInitiatives({});
|
||||||
|
expect(result[0].id).toBe(readyInit.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('same state — most recently updated comes first', async () => {
|
||||||
|
const { caller, initiativeRepo, db } = await setupNoPhaseRepo();
|
||||||
|
const older = await initiativeRepo.create({ name: 'Older' });
|
||||||
|
const newer = await initiativeRepo.create({ name: 'Newer' });
|
||||||
|
// Set different updatedAt values via raw DB (SQLite stores timestamps at second precision)
|
||||||
|
const olderDate = new Date('2024-01-01T00:00:00.000Z');
|
||||||
|
const newerDate = new Date('2024-01-02T00:00:00.000Z');
|
||||||
|
await db.update(initiativesTable).set({ updatedAt: olderDate }).where(eq(initiativesTable.id, older.id));
|
||||||
|
await db.update(initiativesTable).set({ updatedAt: newerDate }).where(eq(initiativesTable.id, newer.id));
|
||||||
|
const result = await caller.listInitiatives({});
|
||||||
|
expect(result[0].id).toBe(newer.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('same state and updatedAt — lexicographically smaller id appears first', async () => {
|
||||||
|
const { caller, initiativeRepo, db } = await setupNoPhaseRepo();
|
||||||
|
const init1 = await initiativeRepo.create({ name: 'Init A' });
|
||||||
|
const init2 = await initiativeRepo.create({ name: 'Init B' });
|
||||||
|
const fixedDate = new Date('2024-01-01T00:00:00.000Z');
|
||||||
|
await db.update(initiativesTable).set({ updatedAt: fixedDate }).where(eq(initiativesTable.id, init1.id));
|
||||||
|
await db.update(initiativesTable).set({ updatedAt: fixedDate }).where(eq(initiativesTable.id, init2.id));
|
||||||
|
const result = await caller.listInitiatives({});
|
||||||
|
const expectedFirst = init1.id < init2.id ? init1.id : init2.id;
|
||||||
|
expect(result[0].id).toBe(expectedFirst);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty list returns []', async () => {
|
||||||
|
const { caller } = await setupNoPhaseRepo();
|
||||||
|
const result = await caller.listInitiatives({});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single item returns array of length 1', async () => {
|
||||||
|
const { caller, initiativeRepo } = await setupNoPhaseRepo();
|
||||||
|
await initiativeRepo.create({ name: 'Only' });
|
||||||
|
const result = await caller.listInitiatives({});
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('updateInitiativeConfig — qualityReview', () => {
|
describe('updateInitiativeConfig — qualityReview', () => {
|
||||||
it('sets qualityReview to true', async () => {
|
it('sets qualityReview to true', async () => {
|
||||||
const { caller, initiative } = await setup();
|
const { caller, initiative } = await setup();
|
||||||
|
|||||||
@@ -11,6 +11,36 @@ import { buildRefinePrompt, buildConflictResolutionPrompt, buildConflictResoluti
|
|||||||
import type { PageForSerialization } from '../../agent/content-serializer.js';
|
import type { PageForSerialization } from '../../agent/content-serializer.js';
|
||||||
import { ensureProjectClone } from '../../git/project-clones.js';
|
import { ensureProjectClone } from '../../git/project-clones.js';
|
||||||
|
|
||||||
|
const ACTIVITY_STATE_PRIORITY: Record<string, number> = {
|
||||||
|
executing: 0,
|
||||||
|
pending_review: 0,
|
||||||
|
discussing: 0,
|
||||||
|
detailing: 0,
|
||||||
|
refining: 0,
|
||||||
|
resolving_conflict: 0,
|
||||||
|
ready: 1,
|
||||||
|
planning: 1,
|
||||||
|
blocked: 2,
|
||||||
|
complete: 3,
|
||||||
|
archived: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
function activityPriority(state: string): number {
|
||||||
|
return ACTIVITY_STATE_PRIORITY[state] ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortInitiatives<T extends { activity: { state: string }; updatedAt: string | Date; id: string }>(enriched: T[]): T[] {
|
||||||
|
return enriched.sort((a, b) => {
|
||||||
|
const pa = activityPriority(a.activity.state);
|
||||||
|
const pb = activityPriority(b.activity.state);
|
||||||
|
if (pa !== pb) return pa - pb;
|
||||||
|
const ta = new Date(a.updatedAt).getTime();
|
||||||
|
const tb = new Date(b.updatedAt).getTime();
|
||||||
|
if (tb !== ta) return tb - ta;
|
||||||
|
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||||
return {
|
return {
|
||||||
createInitiative: publicProcedure
|
createInitiative: publicProcedure
|
||||||
@@ -156,17 +186,18 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
|
|
||||||
if (ctx.phaseRepository) {
|
if (ctx.phaseRepository) {
|
||||||
const phaseRepo = ctx.phaseRepository;
|
const phaseRepo = ctx.phaseRepository;
|
||||||
return Promise.all(initiatives.map(async (init) => {
|
const enriched = await Promise.all(initiatives.map(async (init) => {
|
||||||
const phases = await phaseRepo.findByInitiativeId(init.id);
|
const phases = await phaseRepo.findByInitiativeId(init.id);
|
||||||
return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
|
return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
|
||||||
}));
|
}));
|
||||||
|
return sortInitiatives(enriched);
|
||||||
}
|
}
|
||||||
|
|
||||||
return initiatives.map(init => ({
|
return sortInitiatives(initiatives.map(init => ({
|
||||||
...init,
|
...init,
|
||||||
...addProjects(init),
|
...addProjects(init),
|
||||||
activity: deriveInitiativeActivity(init, [], activeArchitectAgents),
|
activity: deriveInitiativeActivity(init, [], activeArchitectAgents),
|
||||||
}));
|
})));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getInitiative: publicProcedure
|
getInitiative: publicProcedure
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'
|
|||||||
import * as parseModule from '@/lib/parse-agent-output'
|
import * as parseModule from '@/lib/parse-agent-output'
|
||||||
import { AgentOutputViewer } from './AgentOutputViewer'
|
import { AgentOutputViewer } from './AgentOutputViewer'
|
||||||
|
|
||||||
|
const EMPTY_CHUNKS: never[] = []
|
||||||
vi.mock('@/lib/trpc', () => ({
|
vi.mock('@/lib/trpc', () => ({
|
||||||
trpc: {
|
trpc: {
|
||||||
getAgentOutput: {
|
getAgentOutput: {
|
||||||
useQuery: vi.fn(() => ({ data: [], isLoading: false })),
|
useQuery: vi.fn(() => ({ data: EMPTY_CHUNKS, isLoading: false })),
|
||||||
},
|
},
|
||||||
onAgentOutput: {
|
onAgentOutput: {
|
||||||
useSubscription: vi.fn(),
|
useSubscription: vi.fn(),
|
||||||
|
|||||||
184
apps/web/src/components/InitiativeCard.test.tsx
Normal file
184
apps/web/src/components/InitiativeCard.test.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { vi, describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/trpc', () => ({
|
||||||
|
trpc: {
|
||||||
|
useUtils: () => ({ listInitiatives: { invalidate: vi.fn() } }),
|
||||||
|
updateInitiative: { useMutation: vi.fn(() => ({ mutate: vi.fn() })) },
|
||||||
|
deleteInitiative: { useMutation: vi.fn(() => ({ mutate: vi.fn() })) },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/card', () => ({
|
||||||
|
Card: ({ children, className, onClick }: any) => (
|
||||||
|
<div data-testid="card" className={className} onClick={onClick}>{children}</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { InitiativeCard, type SerializedInitiative } from './InitiativeCard'
|
||||||
|
|
||||||
|
function makeInitiative(overrides: Partial<SerializedInitiative> = {}): SerializedInitiative {
|
||||||
|
return {
|
||||||
|
id: 'init-1',
|
||||||
|
name: 'Test Initiative',
|
||||||
|
status: 'active',
|
||||||
|
branch: null,
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
|
projects: [],
|
||||||
|
...overrides,
|
||||||
|
activity: {
|
||||||
|
state: 'planning',
|
||||||
|
activePhase: null,
|
||||||
|
phasesTotal: 0,
|
||||||
|
phasesCompleted: 0,
|
||||||
|
...(overrides.activity ?? {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InitiativeCard — single-row structure', () => {
|
||||||
|
it('renders no second-row div (no mt-1.5 class in DOM)', () => {
|
||||||
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||||
|
expect(document.querySelector('.mt-1\\.5')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flex container has items-center class', () => {
|
||||||
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||||
|
const flexContainer = document.querySelector('.flex.items-center')
|
||||||
|
expect(flexContainer).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('InitiativeCard — project badge overflow', () => {
|
||||||
|
it('1 project → 1 badge with project name, no +N chip', () => {
|
||||||
|
const initiative = makeInitiative({
|
||||||
|
projects: [{ id: 'p1', name: 'Project Alpha' }],
|
||||||
|
})
|
||||||
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
expect(screen.getByText('Project Alpha')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/^\+\d+$/)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3 projects → first 2 names present, third absent, +1 chip', () => {
|
||||||
|
const initiative = makeInitiative({
|
||||||
|
projects: [
|
||||||
|
{ id: 'p1', name: 'Alpha' },
|
||||||
|
{ id: 'p2', name: 'Beta' },
|
||||||
|
{ id: 'p3', name: 'Gamma' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Gamma')).toBeNull()
|
||||||
|
expect(screen.getByText('+1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('5 projects → first 2 names present, +3 chip', () => {
|
||||||
|
const initiative = makeInitiative({
|
||||||
|
projects: [
|
||||||
|
{ id: 'p1', name: 'Alpha' },
|
||||||
|
{ id: 'p2', name: 'Beta' },
|
||||||
|
{ id: 'p3', name: 'Gamma' },
|
||||||
|
{ id: 'p4', name: 'Delta' },
|
||||||
|
{ id: 'p5', name: 'Epsilon' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('+3')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('0 projects → no badge elements rendered', () => {
|
||||||
|
const initiative = makeInitiative({ projects: [] })
|
||||||
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
// No outline or secondary badges for projects
|
||||||
|
expect(document.querySelectorAll('[class*="rounded-full"][class*="border"]').length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('InitiativeCard — hover-reveal menu', () => {
|
||||||
|
it('dropdown wrapper has opacity-0 class', () => {
|
||||||
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||||
|
const wrapper = document.querySelector('.opacity-0')
|
||||||
|
expect(wrapper).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dropdown wrapper has group-hover:opacity-100 class', () => {
|
||||||
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||||
|
const wrapper = document.querySelector('.opacity-0')
|
||||||
|
expect(wrapper).toHaveClass('group-hover:opacity-100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('card has group class', () => {
|
||||||
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||||
|
const card = screen.getByTestId('card')
|
||||||
|
expect(card).toHaveClass('group')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('InitiativeCard — phase counter', () => {
|
||||||
|
it('shows "2 / 5" when phasesCompleted=2 and phasesTotal=5', () => {
|
||||||
|
const initiative = makeInitiative({
|
||||||
|
activity: { state: 'planning', activePhase: null, phasesTotal: 5, phasesCompleted: 2 },
|
||||||
|
})
|
||||||
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
expect(screen.getByText('2 / 5')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counter absent when phasesTotal=0', () => {
|
||||||
|
const initiative = makeInitiative({
|
||||||
|
activity: { state: 'planning', activePhase: null, phasesTotal: 0, phasesCompleted: 0 },
|
||||||
|
})
|
||||||
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
expect(screen.queryByText(/\d+ \/ \d+/)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('InitiativeCard — activity dot pulse', () => {
|
||||||
|
it('state="executing" → StatusDot has animate-status-pulse class', () => {
|
||||||
|
const initiative = makeInitiative({
|
||||||
|
activity: { state: 'executing', activePhase: null, phasesTotal: 0, phasesCompleted: 0 },
|
||||||
|
})
|
||||||
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
const dot = document.querySelector('[role="status"]')
|
||||||
|
expect(dot).toHaveClass('animate-status-pulse')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('state="planning" → StatusDot does not have animate-status-pulse class', () => {
|
||||||
|
const initiative = makeInitiative({
|
||||||
|
activity: { state: 'planning', activePhase: null, phasesTotal: 0, phasesCompleted: 0 },
|
||||||
|
})
|
||||||
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
const dot = document.querySelector('[role="status"]')
|
||||||
|
expect(dot).not.toHaveClass('animate-status-pulse')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('InitiativeCard — active phase name', () => {
|
||||||
|
it('activePhase present → phase name and separator visible', () => {
|
||||||
|
const initiative = makeInitiative({
|
||||||
|
activity: {
|
||||||
|
state: 'planning',
|
||||||
|
activePhase: { id: 'p1', name: 'Phase Alpha' },
|
||||||
|
phasesTotal: 0,
|
||||||
|
phasesCompleted: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
expect(screen.getByText('Phase Alpha')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('·')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activePhase=null → no separator or phase name rendered', () => {
|
||||||
|
const initiative = makeInitiative({
|
||||||
|
activity: { state: 'planning', activePhase: null, phasesTotal: 0, phasesCompleted: 0 },
|
||||||
|
})
|
||||||
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
expect(screen.queryByText('·')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { StatusDot, type StatusVariant } from "@/components/StatusDot";
|
import { StatusDot, type StatusVariant } from "@/components/StatusDot";
|
||||||
import { ProgressBar } from "@/components/ProgressBar";
|
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
|
|
||||||
/** Initiative shape as returned by tRPC (Date serialized to string over JSON) */
|
/** Initiative shape as returned by tRPC (Date serialized to string over JSON) */
|
||||||
@@ -24,7 +23,7 @@ export interface SerializedInitiative {
|
|||||||
projects?: Array<{ id: string; name: string }>;
|
projects?: Array<{ id: string; name: string }>;
|
||||||
activity: {
|
activity: {
|
||||||
state: string;
|
state: string;
|
||||||
activePhase?: { id: string; name: string };
|
activePhase?: { id: string; name: string } | null;
|
||||||
phasesTotal: number;
|
phasesTotal: number;
|
||||||
phasesCompleted: number;
|
phasesCompleted: number;
|
||||||
};
|
};
|
||||||
@@ -83,27 +82,56 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
|
|||||||
|
|
||||||
const { activity } = initiative;
|
const { activity } = initiative;
|
||||||
const visual = activityVisual(activity.state);
|
const visual = activityVisual(activity.state);
|
||||||
|
const projects = initiative.projects ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
interactive
|
interactive
|
||||||
className="p-4"
|
className="px-4 py-2 group"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{/* Row 1: Name + project pills + overflow menu */}
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<StatusDot
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
status={activity.state}
|
||||||
<span className="shrink-0 text-base font-bold">
|
variant={visual.variant}
|
||||||
{initiative.name}
|
size="sm"
|
||||||
|
pulse={visual.pulse}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="truncate min-w-0 flex-1 font-semibold text-sm">
|
||||||
|
{initiative.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{activity.activePhase && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground text-sm shrink-0">·</span>
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{activity.activePhase.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activity.phasesTotal > 0 && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground whitespace-nowrap shrink-0">
|
||||||
|
{activity.phasesCompleted} / {activity.phasesTotal}
|
||||||
</span>
|
</span>
|
||||||
{initiative.projects && initiative.projects.length > 0 &&
|
)}
|
||||||
initiative.projects.map((p) => (
|
|
||||||
<Badge key={p.id} variant="outline" size="xs" className="shrink-0 font-normal">
|
{projects.slice(0, 2).map((p) => (
|
||||||
{p.name}
|
<Badge key={p.id} variant="outline" size="xs" className="shrink-0">
|
||||||
</Badge>
|
{p.name}
|
||||||
))}
|
</Badge>
|
||||||
</div>
|
))}
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
{projects.length > 2 && (
|
||||||
|
<Badge variant="secondary" size="xs" className="shrink-0">
|
||||||
|
+{projects.length - 2}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
@@ -123,35 +151,6 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Activity dot + label + active phase + progress */}
|
|
||||||
<div className="mt-1.5 flex items-center gap-3">
|
|
||||||
<StatusDot
|
|
||||||
status={activity.state}
|
|
||||||
variant={visual.variant}
|
|
||||||
size="sm"
|
|
||||||
pulse={visual.pulse}
|
|
||||||
label={visual.label}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">{visual.label}</span>
|
|
||||||
{activity.activePhase && (
|
|
||||||
<span className="truncate text-sm text-muted-foreground">
|
|
||||||
{activity.activePhase.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{activity.phasesTotal > 0 && (
|
|
||||||
<>
|
|
||||||
<ProgressBar
|
|
||||||
completed={activity.phasesCompleted}
|
|
||||||
total={activity.phasesTotal}
|
|
||||||
className="ml-auto w-24"
|
|
||||||
/>
|
|
||||||
<span className="hidden text-xs text-muted-foreground md:inline">
|
|
||||||
{activity.phasesCompleted}/{activity.phasesTotal}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
69
apps/web/src/components/hq/HQWaitingForInputSection.tsx
Normal file
69
apps/web/src/components/hq/HQWaitingForInputSection.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRUNCATE_LENGTH = 120
|
||||||
|
|
||||||
|
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 isTruncated = item.questionText.length > TRUNCATE_LENGTH
|
||||||
|
const displayText = isTruncated
|
||||||
|
? item.questionText.slice(0, TRUNCATE_LENGTH) + '…'
|
||||||
|
: item.questionText
|
||||||
|
|
||||||
|
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-semibold text-sm">
|
||||||
|
{item.agentName}
|
||||||
|
{item.initiativeId && item.initiativeName && (
|
||||||
|
<span className="font-normal text-muted-foreground"> · {item.initiativeName}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 cursor-default">
|
||||||
|
{displayText}
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{isTruncated && (
|
||||||
|
<TooltipContent forceMount>
|
||||||
|
{item.questionText}
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
waiting {formatRelativeTime(item.waitingSince)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate({ to: '/inbox' })}
|
||||||
|
>
|
||||||
|
Answer
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
apps/web/src/routes/initiatives/index.test.tsx
Normal file
104
apps/web/src/routes/initiatives/index.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock("@tanstack/react-router", () => ({
|
||||||
|
createFileRoute: () => () => ({}),
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/trpc", () => ({
|
||||||
|
trpc: {
|
||||||
|
listProjects: { useQuery: () => ({ data: [] }) },
|
||||||
|
useUtils: () => ({}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks", () => ({
|
||||||
|
useLiveUpdates: vi.fn(),
|
||||||
|
INITIATIVE_LIST_RULES: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/InitiativeList", () => ({
|
||||||
|
InitiativeList: () => <div data-testid="initiative-list" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/CreateInitiativeDialog", () => ({
|
||||||
|
CreateInitiativeDialog: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Import after mocks ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { DashboardPage } from "@/routes/initiatives/index";
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(<DashboardPage />);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("DashboardPage — statusFilter default and sessionStorage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to 'active' when sessionStorage is empty", () => {
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByRole("combobox", { name: /status/i }) as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays the 'Active' option as selected on first render", () => {
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Active") as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads 'completed' from sessionStorage as initial value", () => {
|
||||||
|
sessionStorage.setItem("initiatives.statusFilter", "completed");
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Completed") as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads 'archived' from sessionStorage as initial value", () => {
|
||||||
|
sessionStorage.setItem("initiatives.statusFilter", "archived");
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Archived") as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("archived");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'active' when sessionStorage contains an invalid value", () => {
|
||||||
|
sessionStorage.setItem("initiatives.statusFilter", "bogus");
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Active") as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the new value to sessionStorage when the filter changes", () => {
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Active");
|
||||||
|
fireEvent.change(select, { target: { value: "all" } });
|
||||||
|
expect(sessionStorage.getItem("initiatives.statusFilter")).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the select's displayed value after the filter changes", () => {
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Active");
|
||||||
|
fireEvent.change(select, { target: { value: "completed" } });
|
||||||
|
expect((select as HTMLSelectElement).value).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes 'archived' to sessionStorage when filter changes to archived", () => {
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Active");
|
||||||
|
fireEvent.change(select, { target: { value: "archived" } });
|
||||||
|
expect(sessionStorage.getItem("initiatives.statusFilter")).toBe("archived");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,11 +21,35 @@ const filterOptions: { value: StatusFilter; label: string }[] = [
|
|||||||
{ value: "archived", label: "Archived" },
|
{ value: "archived", label: "Archived" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>(() => {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem("initiatives.statusFilter");
|
||||||
|
if (
|
||||||
|
stored === "all" ||
|
||||||
|
stored === "active" ||
|
||||||
|
stored === "completed" ||
|
||||||
|
stored === "archived"
|
||||||
|
) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable (SSR, private-browsing restriction, etc.)
|
||||||
|
}
|
||||||
|
return "active";
|
||||||
|
});
|
||||||
const [projectFilter, setProjectFilter] = useState<string>("all");
|
const [projectFilter, setProjectFilter] = useState<string>("all");
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleStatusFilterChange = (value: StatusFilter) => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("initiatives.statusFilter", value);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setStatusFilter(value);
|
||||||
|
};
|
||||||
const projectsQuery = trpc.listProjects.useQuery();
|
const projectsQuery = trpc.listProjects.useQuery();
|
||||||
|
|
||||||
// Single SSE stream for live updates
|
// Single SSE stream for live updates
|
||||||
@@ -55,9 +79,10 @@ function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
|
aria-label="Status"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setStatusFilter(e.target.value as StatusFilter)
|
handleStatusFilterChange(e.target.value as StatusFilter)
|
||||||
}
|
}
|
||||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@carealytix/codewalkers",
|
"name": "@carealytix/codewalkers",
|
||||||
"version": "0.0.1",
|
"version": "1.1.0",
|
||||||
"description": "Multi-agent workspace for orchestrating multiple AI coding agents",
|
"description": "Multi-agent workspace for orchestrating multiple AI coding agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
Reference in New Issue
Block a user