Merge branch 'cw/improve-inbox-ui-on-hq' into cw-merge-1772839945283

This commit is contained in:
Lukas May
2026-03-07 00:32:25 +01:00
15 changed files with 512 additions and 20 deletions

View File

@@ -139,6 +139,7 @@ describe('MultiProviderAgentManager', () => {
findByStatus: vi.fn().mockResolvedValue([mockAgent]),
update: vi.fn().mockResolvedValue(mockAgent),
delete: vi.fn().mockResolvedValue(undefined),
findWaitingWithContext: vi.fn().mockResolvedValue([]),
};
mockProjectRepository = {

View File

@@ -46,7 +46,8 @@ describe('OutputHandler completion mutex', () => {
async findByTaskId() { throw new Error('Not implemented'); },
async findByName() { throw new Error('Not implemented'); },
async findBySessionId() { throw new Error('Not implemented'); },
async delete() { throw new Error('Not implemented'); }
async delete() { throw new Error('Not implemented'); },
async findWaitingWithContext() { throw new Error('Not implemented'); }
};
beforeEach(() => {

View File

@@ -8,6 +8,14 @@
import type { Agent } from '../schema.js';
import type { AgentMode } from '../../agent/types.js';
/** Agent row enriched with joined task/phase/initiative context fields. */
export interface AgentWithContext extends Agent {
taskName: string | null;
phaseName: string | null;
initiativeName: string | null;
taskDescription: string | null;
}
/**
* Agent status values.
*/
@@ -117,4 +125,10 @@ export interface AgentRepository {
* Throws if agent not found.
*/
delete(id: string): Promise<void>;
/**
* Find all agents with status 'waiting_for_input', enriched with
* task, phase, and initiative names via LEFT JOINs.
*/
findWaitingWithContext(): Promise<AgentWithContext[]>;
}

View File

@@ -277,3 +277,91 @@ describe('DrizzleAgentRepository', () => {
});
});
});
describe('DrizzleAgentRepository.findWaitingWithContext()', () => {
let agentRepo: DrizzleAgentRepository;
let taskRepo: DrizzleTaskRepository;
let phaseRepo: DrizzlePhaseRepository;
let initiativeRepo: DrizzleInitiativeRepository;
beforeEach(() => {
const db = createTestDatabase();
agentRepo = new DrizzleAgentRepository(db);
taskRepo = new DrizzleTaskRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
initiativeRepo = new DrizzleInitiativeRepository(db);
});
it('returns empty array when no waiting agents exist', async () => {
const result = await agentRepo.findWaitingWithContext();
expect(result).toEqual([]);
});
it('only returns agents with status waiting_for_input', async () => {
await agentRepo.create({ name: 'running-agent', worktreeId: 'wt1', status: 'running' });
await agentRepo.create({ name: 'waiting-agent', worktreeId: 'wt2', status: 'waiting_for_input' });
const result = await agentRepo.findWaitingWithContext();
expect(result).toHaveLength(1);
expect(result[0].name).toBe('waiting-agent');
});
it('populates taskName, phaseName, initiativeName, taskDescription when FK associations exist', async () => {
const initiative = await initiativeRepo.create({ name: 'My Initiative' });
const phase = await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1' });
const task = await taskRepo.create({
phaseId: phase.id,
name: 'Implement feature',
description: 'Write the feature code',
});
await agentRepo.create({
name: 'ctx-agent',
worktreeId: 'wt3',
status: 'waiting_for_input',
taskId: task.id,
initiativeId: initiative.id,
});
const result = await agentRepo.findWaitingWithContext();
expect(result).toHaveLength(1);
expect(result[0].taskName).toBe('Implement feature');
expect(result[0].phaseName).toBe('Phase 1');
expect(result[0].initiativeName).toBe('My Initiative');
expect(result[0].taskDescription).toBe('Write the feature code');
});
it('returns null for context fields when agent has no taskId or initiativeId', async () => {
await agentRepo.create({ name: 'bare-agent', worktreeId: 'wt4', status: 'waiting_for_input' });
const result = await agentRepo.findWaitingWithContext();
expect(result).toHaveLength(1);
expect(result[0].taskName).toBeNull();
expect(result[0].phaseName).toBeNull();
expect(result[0].initiativeName).toBeNull();
expect(result[0].taskDescription).toBeNull();
});
it('returns null phaseName when task has no phaseId', async () => {
const initiative = await initiativeRepo.create({ name: 'Orphan Init' });
const task = await taskRepo.create({
phaseId: null,
name: 'Orphan Task',
description: null,
});
await agentRepo.create({
name: 'orphan-agent',
worktreeId: 'wt5',
status: 'waiting_for_input',
taskId: task.id,
initiativeId: initiative.id,
});
const result = await agentRepo.findWaitingWithContext();
expect(result).toHaveLength(1);
expect(result[0].phaseName).toBeNull();
expect(result[0].taskName).toBe('Orphan Task');
expect(result[0].initiativeName).toBe('Orphan Init');
});
});

View File

@@ -4,13 +4,14 @@
* Implements AgentRepository interface using Drizzle ORM.
*/
import { eq } from 'drizzle-orm';
import { eq, getTableColumns } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { agents, type Agent } from '../../schema.js';
import { agents, tasks, phases, initiatives, type Agent } from '../../schema.js';
import type {
AgentRepository,
AgentStatus,
AgentWithContext,
CreateAgentData,
UpdateAgentData,
} from '../agent-repository.js';
@@ -116,4 +117,20 @@ export class DrizzleAgentRepository implements AgentRepository {
throw new Error(`Agent not found: ${id}`);
}
}
async findWaitingWithContext(): Promise<AgentWithContext[]> {
return this.db
.select({
...getTableColumns(agents),
taskName: tasks.name,
phaseName: phases.name,
initiativeName: initiatives.name,
taskDescription: tasks.description,
})
.from(agents)
.where(eq(agents.status, 'waiting_for_input'))
.leftJoin(tasks, eq(agents.taskId, tasks.id))
.leftJoin(phases, eq(tasks.phaseId, phases.id))
.leftJoin(initiatives, eq(agents.initiativeId, initiatives.id));
}
}

View File

@@ -23,6 +23,7 @@ import type { ConversationRepository } from '../db/repositories/conversation-rep
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 { AgentRepository } from '../db/repositories/agent-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';
@@ -85,6 +86,8 @@ export interface TrpcAdapterOptions {
projectSyncManager?: ProjectSyncManager;
/** Errand repository for errand CRUD operations */
errandRepository?: ErrandRepository;
/** Agent repository for enriched agent queries */
agentRepository?: AgentRepository;
/** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string;
}
@@ -170,6 +173,7 @@ export function createTrpcHandler(options: TrpcAdapterOptions) {
reviewCommentRepository: options.reviewCommentRepository,
projectSyncManager: options.projectSyncManager,
errandRepository: options.errandRepository,
agentRepository: options.agentRepository,
workspaceRoot: options.workspaceRoot,
}),
});

View File

@@ -96,7 +96,8 @@ describe('Crash marking race condition', () => {
async findByTaskId() { throw new Error('Not implemented'); },
async findByName() { throw new Error('Not implemented'); },
async findBySessionId() { throw new Error('Not implemented'); },
async delete() { throw new Error('Not implemented'); }
async delete() { throw new Error('Not implemented'); },
async findWaitingWithContext() { throw new Error('Not implemented'); }
};
outputHandler = new OutputHandler(mockRepo);

View File

@@ -20,6 +20,7 @@ import type { ConversationRepository } from '../db/repositories/conversation-rep
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 { AgentRepository } from '../db/repositories/agent-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';
@@ -87,6 +88,8 @@ export interface TRPCContext {
projectSyncManager?: ProjectSyncManager;
/** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string;
/** Agent repository for enriched queries (e.g., findWaitingWithContext) */
agentRepository?: AgentRepository;
}
/**
@@ -119,6 +122,7 @@ export interface CreateContextOptions {
errandRepository?: ErrandRepository;
projectSyncManager?: ProjectSyncManager;
workspaceRoot?: string;
agentRepository?: AgentRepository;
}
/**
@@ -155,5 +159,6 @@ export function createContext(options: CreateContextOptions): TRPCContext {
errandRepository: options.errandRepository,
projectSyncManager: options.projectSyncManager,
workspaceRoot: options.workspaceRoot,
agentRepository: options.agentRepository,
};
}

View File

@@ -20,6 +20,7 @@ import type { ConversationRepository } from '../../db/repositories/conversation-
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 { AgentRepository } from '../../db/repositories/agent-repository.js';
import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js';
import type { CoordinationManager } from '../../coordination/types.js';
import type { BranchManager } from '../../git/branch-manager.js';
@@ -236,3 +237,13 @@ export function requireErrandRepository(ctx: TRPCContext): ErrandRepository {
}
return ctx.errandRepository;
}
export function requireAgentRepository(ctx: TRPCContext): AgentRepository {
if (!ctx.agentRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Agent repository not available',
});
}
return ctx.agentRepository;
}

View File

@@ -11,7 +11,7 @@ import type { ProcedureBuilder } from '../trpc.js';
import type { TRPCContext } from '../context.js';
import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js';
import type { AgentOutputEvent } from '../../events/types.js';
import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository, requireConversationRepository } from './_helpers.js';
import { requireAgentManager, requireAgentRepository, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository, requireConversationRepository } from './_helpers.js';
export type AgentRadarRow = {
id: string;
@@ -191,9 +191,8 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
listWaitingAgents: publicProcedure
.query(async ({ ctx }) => {
const agentManager = requireAgentManager(ctx);
const allAgents = await agentManager.list();
return allAgents.filter(agent => agent.status === 'waiting_for_input');
const agentRepo = requireAgentRepository(ctx);
return agentRepo.findWaitingWithContext();
}),
getActiveRefineAgent: publicProcedure

View File

@@ -0,0 +1,262 @@
// @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'
import { InboxDetailPanel } from './InboxDetailPanel'
// Mock trpc to control getAgentOutput
vi.mock('@/lib/trpc', () => ({
trpc: {
getAgentOutput: {
useQuery: vi.fn(() => ({ data: [], isLoading: false })),
},
},
}))
// Mock @tanstack/react-router's Link
vi.mock('@tanstack/react-router', () => ({
Link: ({ children, params }: { children: React.ReactNode; params?: Record<string, string> }) => (
<a href={params?.initiativeId ?? '#'}>{children}</a>
),
}))
// Mock AgentOutputViewer
vi.mock('./AgentOutputViewer', () => ({
AgentOutputViewer: () => <div data-testid="agent-output-viewer" />,
}))
function makeAgent(overrides?: Partial<ReturnType<typeof makeAgent>>): {
id: string
name: string
status: string
taskId: string | null
taskName: string | null
phaseName: string | null
initiativeName: string | null
initiativeId: string | null
updatedAt: string
} {
return {
id: 'agent-1',
name: 'Test Agent',
status: 'waiting_for_input',
taskId: 'task-1',
taskName: 'Implement auth',
phaseName: 'Phase 1',
initiativeName: 'My Initiative',
initiativeId: 'init-1',
updatedAt: new Date().toISOString(),
...overrides,
}
}
function renderPanel(
agentOverrides?: Parameters<typeof makeAgent>[0],
propOverrides?: {
taskDescription?: string | null
isLoadingContext?: boolean
questions?: any[]
isLoadingQuestions?: boolean
message?: { id: string; content: string; requiresResponse: boolean } | null
}
) {
const agent = makeAgent(agentOverrides)
const props = {
taskDescription: null as string | null,
isLoadingContext: false,
questions: [] as any[],
isLoadingQuestions: false,
message: null as { id: string; content: string; requiresResponse: boolean } | null,
questionsError: null,
onBack: vi.fn(),
onSubmitAnswers: vi.fn(),
onDismissQuestions: vi.fn(),
onDismissMessage: vi.fn(),
isSubmitting: false,
isDismissingQuestions: false,
isDismissingMessage: false,
submitError: null,
dismissMessageError: null,
...propOverrides,
}
return render(<InboxDetailPanel agent={agent} {...props} />)
}
import * as trpcModule from '@/lib/trpc'
describe('InboxDetailPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default: empty output
vi.mocked(trpcModule.trpc.getAgentOutput.useQuery).mockReturnValue({
data: [],
isLoading: false,
} as any)
})
// -------------------------
// Task name header tests
// -------------------------
it('task name header — shows taskName when set', () => {
renderPanel({ taskName: 'Implement auth', taskId: 'some-uuid-1234' })
expect(screen.getByText(/Task: Implement auth/)).toBeInTheDocument()
expect(screen.queryByText(/some-uuid-1234/)).not.toBeInTheDocument()
})
it('task name header — falls back to taskId when taskName is null', () => {
renderPanel({ taskName: null, taskId: 'abc-uuid' })
expect(screen.getByText(/Task:.*abc-uuid/)).toBeInTheDocument()
})
it('task name header — shows em dash when both are null', () => {
renderPanel({ taskName: null, taskId: null })
expect(screen.getByText(/Task:.*—/)).toBeInTheDocument()
})
// -------------------------
// Context panel tests
// -------------------------
it('context panel — loaded, with task, shows all fields', () => {
renderPanel(
{
taskId: 'task-1',
taskName: 'Impl auth',
initiativeName: 'My Initiative',
initiativeId: 'init-1',
phaseName: 'Phase 1',
},
{
taskDescription: 'Some description',
isLoadingContext: false,
}
)
expect(screen.getByText('My Initiative')).toBeInTheDocument()
// Task name in context panel
expect(screen.getAllByText(/Impl auth/).length).toBeGreaterThan(0)
expect(screen.getByText('Some description')).toBeInTheDocument()
expect(screen.getByText(/Phase 1/)).toBeInTheDocument()
})
it('context panel — truncates description longer than 300 chars', () => {
const longDescription = 'A'.repeat(301)
renderPanel(
{ taskId: 'task-1' },
{ taskDescription: longDescription, isLoadingContext: false }
)
const truncated = screen.getByText(/A+…/)
const text = truncated.textContent ?? ''
expect(text.endsWith('…')).toBe(true)
expect(text.length).toBeLessThanOrEqual(301)
})
it('context panel — strips HTML tags from description', () => {
renderPanel(
{ taskId: 'task-1' },
{ taskDescription: '<p>Some text</p>', isLoadingContext: false }
)
expect(screen.getByText('Some text')).toBeInTheDocument()
// <p> should not appear as literal text
expect(screen.queryByText(/<p>/)).not.toBeInTheDocument()
// The rendered content should not contain raw HTML tags
const container = document.querySelector('body')!
expect(container.innerHTML).not.toContain('&lt;p&gt;')
})
it('context panel — shows "No task context available" when taskId is null', () => {
renderPanel({ taskId: null }, { isLoadingContext: false })
expect(screen.getByText('No task context available')).toBeInTheDocument()
})
it('context panel — shows skeleton when loading', () => {
renderPanel({ taskId: 'task-1' }, { isLoadingContext: true })
// Skeletons are rendered by class
const skeletons = document.querySelectorAll('[class*="animate-pulse"]')
expect(skeletons.length).toBeGreaterThan(0)
// Context content NOT shown
expect(screen.queryByText('No task context available')).not.toBeInTheDocument()
expect(screen.queryByText(/Initiative:/)).not.toBeInTheDocument()
})
// -------------------------
// Logs section tests
// -------------------------
it('logs section — hidden when output is empty', () => {
vi.mocked(trpcModule.trpc.getAgentOutput.useQuery).mockReturnValue({
data: [],
isLoading: false,
} as any)
renderPanel()
expect(screen.queryByText('Show agent logs')).not.toBeInTheDocument()
})
it('logs section — shows button and badge when chunks exist, clicking shows viewer', () => {
vi.mocked(trpcModule.trpc.getAgentOutput.useQuery).mockReturnValue({
data: [{}, {}, {}],
isLoading: false,
} as any)
renderPanel()
const button = screen.getByText('Show agent logs')
expect(button).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
fireEvent.click(button)
expect(screen.getByTestId('agent-output-viewer')).toBeInTheDocument()
})
it('logs section — collapses when agent id changes', () => {
vi.mocked(trpcModule.trpc.getAgentOutput.useQuery).mockReturnValue({
data: [{}, {}, {}],
isLoading: false,
} as any)
const { rerender } = renderPanel({ id: 'a' })
// Open logs
fireEvent.click(screen.getByText('Show agent logs'))
expect(screen.getByTestId('agent-output-viewer')).toBeInTheDocument()
// Re-render with different agent id
const newAgent = makeAgent({ id: 'b' })
rerender(
<InboxDetailPanel
agent={newAgent}
taskDescription={null}
isLoadingContext={false}
questions={[]}
isLoadingQuestions={false}
questionsError={null}
message={null}
onBack={vi.fn()}
onSubmitAnswers={vi.fn()}
onDismissQuestions={vi.fn()}
onDismissMessage={vi.fn()}
isSubmitting={false}
isDismissingQuestions={false}
isDismissingMessage={false}
submitError={null}
dismissMessageError={null}
/>
)
// Logs should be collapsed
expect(screen.queryByTestId('agent-output-viewer')).not.toBeInTheDocument()
expect(screen.getByText('Show agent logs')).toBeInTheDocument()
})
})

View File

@@ -1,8 +1,18 @@
import { Link } from "@tanstack/react-router";
import { ChevronLeft } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { QuestionForm } from "@/components/QuestionForm";
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
import { formatRelativeTime } from "@/lib/utils";
import { trpc } from "@/lib/trpc";
function processDescription(content: string | null): string | null {
if (!content) return null;
const stripped = content.replace(/<[^>]*>/g, '');
return stripped.length > 300 ? stripped.slice(0, 300) + '…' : stripped;
}
interface InboxDetailPanelProps {
agent: {
@@ -10,8 +20,14 @@ interface InboxDetailPanelProps {
name: string;
status: string;
taskId: string | null;
taskName: string | null;
phaseName: string | null;
initiativeName: string | null;
initiativeId: string | null;
updatedAt: string;
};
taskDescription: string | null;
isLoadingContext: boolean;
message: {
id: string;
content: string;
@@ -40,6 +56,8 @@ interface InboxDetailPanelProps {
export function InboxDetailPanel({
agent,
taskDescription,
isLoadingContext,
message,
questions,
isLoadingQuestions,
@@ -54,6 +72,22 @@ export function InboxDetailPanel({
submitError,
dismissMessageError,
}: InboxDetailPanelProps) {
const [logsOpen, setLogsOpen] = useState(false);
const logsContainerRef = useRef<HTMLDivElement>(null);
const outputQuery = trpc.getAgentOutput.useQuery({ id: agent.id });
useEffect(() => {
setLogsOpen(false);
}, [agent.id]);
useEffect(() => {
if (logsOpen && logsContainerRef.current) {
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
}
}, [logsOpen]);
const processedDescription = processDescription(taskDescription);
return (
<div className="space-y-4 rounded-lg border border-border p-4">
{/* Mobile back button */}
@@ -82,16 +116,7 @@ export function InboxDetailPanel({
</div>
<p className="mt-1 text-xs text-muted-foreground">
Task:{" "}
{agent.taskId ? (
<Link
to="/initiatives"
className="text-primary hover:underline"
>
{agent.taskId}
</Link>
) : (
"\u2014"
)}
{agent.taskName ?? agent.taskId ?? "—"}
</p>
{agent.taskId && (
<Link
@@ -103,6 +128,39 @@ export function InboxDetailPanel({
)}
</div>
{/* Related Context Panel */}
{isLoadingContext ? (
<div className="space-y-2 py-2">
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-56" />
<Skeleton className="h-3 w-48" />
</div>
) : agent.taskId ? (
<div className="space-y-1 rounded-md bg-muted/40 p-3 text-xs text-muted-foreground">
{agent.initiativeName && agent.initiativeId && (
<p>
Initiative:{" "}
<Link
to="/initiatives/$initiativeId"
params={{ initiativeId: agent.initiativeId }}
className="text-primary hover:underline"
>
{agent.initiativeName}
</Link>
</p>
)}
{agent.taskName && (
<p>Task: <span className="text-foreground">{agent.taskName}</span></p>
)}
{processedDescription && (
<p className="text-muted-foreground">{processedDescription}</p>
)}
{agent.phaseName && <p>Phase: {agent.phaseName}</p>}
</div>
) : (
<p className="text-xs text-muted-foreground">No task context available</p>
)}
{/* Question Form or Notification Content */}
{isLoadingQuestions && (
<div className="py-4 text-center text-sm text-muted-foreground">
@@ -166,6 +224,31 @@ export function InboxDetailPanel({
</p>
</div>
)}
{/* Agent Logs Section */}
{!logsOpen && (outputQuery.data?.length ?? 0) > 0 && (
<div className="pt-2">
<button
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setLogsOpen(true)}
>
Show agent logs
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] tabular-nums">
{outputQuery.isLoading ? "…" : outputQuery.data?.length}
</span>
</button>
</div>
)}
{logsOpen && (
<div
className="mt-2 overflow-y-auto rounded-md"
style={{ maxHeight: 300 }}
ref={logsContainerRef}
>
<AgentOutputViewer agentId={agent.id} />
</div>
)}
</div>
);
}

View File

@@ -219,8 +219,14 @@ export function HeadquartersPage() {
name: selectedAgent.name,
status: selectedAgent.status,
taskId: selectedAgent.taskId ?? null,
taskName: selectedAgent.taskName ?? null,
phaseName: selectedAgent.phaseName ?? null,
initiativeName: selectedAgent.initiativeName ?? null,
initiativeId: selectedAgent.initiativeId ?? null,
updatedAt: String(selectedAgent.updatedAt),
}}
isLoadingContext={agentsQuery.isLoading}
taskDescription={selectedAgent.taskDescription ?? null}
message={
selectedMessage
? {

View File

@@ -241,7 +241,7 @@ Index: `(phaseId)`.
| InitiativeRepository | create, findById, findAll, findByStatus, update, delete |
| PhaseRepository | + createDependency, getDependencies, getDependents, findByInitiativeId |
| TaskRepository | + findByParentTaskId, findByPhaseId, createDependency |
| AgentRepository | + findByName, findByTaskId, findBySessionId, findByStatus |
| AgentRepository | + findByName, findByTaskId, findBySessionId, findByStatus, findWaitingWithContext (LEFT JOIN enriched) |
| MessageRepository | + findPendingForUser, findRequiringResponse, findReplies |
| PageRepository | + findRootPage, getOrCreateRootPage, findByParentPageId |
| ProjectRepository | + junction ops: setInitiativeProjects (diff-based), findProjectsByInitiativeId |

View File

@@ -68,7 +68,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| getAgentPrompt | query | Assembled prompt — reads from DB (`agents.prompt`) first; falls back to `.cw/agent-logs/<name>/PROMPT.md` for pre-persistence agents (1 MB cap) |
| getActiveRefineAgent | query | Active refine agent for initiative |
| getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) |
| listWaitingAgents | query | Agents waiting for input |
| listWaitingAgents | query | Agents waiting for input — returns `AgentWithContext[]` enriched with `taskName`, `phaseName`, `initiativeName`, `taskDescription` via SQL LEFT JOINs |
| listForRadar | query | Radar page: per-agent metrics (questionsCount, messagesCount, subagentsCount, compactionsCount) with time/status/mode/initiative filters |
| getCompactionEvents | query | Compaction events for one agent: `{agentId}``{timestamp, sessionNumber}[]` (cap 200) |
| getSubagentSpawns | query | Subagent spawn events for one agent: `{agentId}``{timestamp, description, promptPreview, fullPrompt}[]` (cap 200) |