feat: Add persistent chat sessions for iterative phase/task refinement

Introduces a chat loop where users send instructions to an agent that
applies changes (create/update/delete phases, tasks, pages) and stays
alive for follow-up messages. Includes schema + migration, repository
layer, chat prompt, file-io action field extension, output handler chat
mode, revert support for deletes, tRPC procedures, events, frontend
slide-over UI with inline changeset display and revert, and docs.
This commit is contained in:
Lukas May
2026-03-04 10:14:28 +01:00
parent d6fb1abcba
commit fcf822363c
40 changed files with 1414 additions and 27 deletions

View File

@@ -37,6 +37,7 @@ export interface ParsedPhaseFile {
title: string;
dependencies: string[];
body: string;
action?: 'create' | 'update' | 'delete';
}
export interface ParsedTaskFile {
@@ -46,6 +47,9 @@ export interface ParsedTaskFile {
type: string;
dependencies: string[];
body: string;
action?: 'create' | 'update' | 'delete';
phaseId?: string;
parentTaskId?: string;
}
export interface ParsedDecisionFile {
@@ -61,6 +65,7 @@ export interface ParsedPageFile {
title: string;
summary: string;
body: string;
action?: 'create' | 'update' | 'delete';
}
// =============================================================================
@@ -324,11 +329,13 @@ export function readPhaseFiles(agentWorkdir: string): ParsedPhaseFile[] {
return readFrontmatterDir(dirPath, (data, body, filename) => {
const id = filename.replace(/\.md$/, '');
const deps = Array.isArray(data.dependencies) ? data.dependencies.map(String) : [];
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
return {
id,
title: String(data.title ?? ''),
dependencies: deps,
body,
action,
};
});
}
@@ -338,6 +345,7 @@ export function readTaskFiles(agentWorkdir: string): ParsedTaskFile[] {
return readFrontmatterDir(dirPath, (data, body, filename) => {
const id = filename.replace(/\.md$/, '');
const deps = Array.isArray(data.dependencies) ? data.dependencies.map(String) : [];
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
return {
id,
title: String(data.title ?? ''),
@@ -345,6 +353,9 @@ export function readTaskFiles(agentWorkdir: string): ParsedTaskFile[] {
type: String(data.type ?? 'auto'),
dependencies: deps,
body,
action,
phaseId: data.phaseId ? String(data.phaseId) : undefined,
parentTaskId: data.parentTaskId ? String(data.parentTaskId) : undefined,
};
});
}
@@ -367,11 +378,13 @@ export function readPageFiles(agentWorkdir: string): ParsedPageFile[] {
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
return readFrontmatterDir(dirPath, (data, body, filename) => {
const pageId = filename.replace(/\.md$/, '');
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
return {
pageId,
title: String(data.title ?? ''),
summary: String(data.summary ?? ''),
body,
action,
};
});
}

View File

@@ -26,6 +26,7 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js';
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import { generateUniqueAlias } from './alias.js';
import type {
EventBus,
@@ -81,11 +82,12 @@ export class MultiProviderAgentManager implements AgentManager {
private logChunkRepository?: LogChunkRepository,
private debug: boolean = false,
processManagerOverride?: ProcessManager,
private chatSessionRepository?: ChatSessionRepository,
) {
this.signalManager = new FileSystemSignalManager();
this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository);
this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager);
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager);
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository);
this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager);
this.lifecycleController = createLifecycleController({
repository,

View File

@@ -14,6 +14,7 @@ import type { ChangeSetRepository, CreateChangeSetEntryData } from '../db/reposi
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import type {
EventBus,
AgentStoppedEvent,
@@ -90,6 +91,7 @@ export class OutputHandler {
private taskRepository?: TaskRepository,
private pageRepository?: PageRepository,
private signalManager?: SignalManager,
private chatSessionRepository?: ChatSessionRepository,
) {}
/**
@@ -390,6 +392,10 @@ export class OutputHandler {
await this.processOutputFiles(agentId, agent, mode, getAgentWorkdir);
break;
case 'questions':
// Chat mode: process output files before handling questions
if (mode === 'chat') {
await this.processOutputFiles(agentId, agent, mode, getAgentWorkdir);
}
await this.handleQuestions(agentId, agent, signal.questions, sessionId);
break;
case 'error':
@@ -655,6 +661,194 @@ export class OutputHandler {
}
break;
}
case 'chat': {
const chatPhases = readPhaseFiles(agentWorkdir);
const chatTasks = readTaskFiles(agentWorkdir);
const chatPages = readPageFiles(agentWorkdir);
if (canWriteChangeSets) {
const entries: CreateChangeSetEntryData[] = [];
let sortOrd = 0;
// Process phases
if (this.phaseRepository) {
for (const p of chatPhases) {
try {
const action = p.action ?? 'create';
if (action === 'create') {
const tiptapContent = p.body ? JSON.stringify(markdownToTiptapJson(p.body)) : undefined;
const created = await this.phaseRepository.create({
id: p.id ?? undefined,
initiativeId: initiativeId!,
name: p.title,
content: tiptapContent,
});
entries.push({ entityType: 'phase', entityId: created.id, action: 'create', newState: JSON.stringify(created), sortOrder: sortOrd++ });
} else if (action === 'update') {
const existing = await this.phaseRepository.findById(p.id);
if (!existing) continue;
const previousState = JSON.stringify(existing);
const tiptapContent = p.body ? JSON.stringify(markdownToTiptapJson(p.body)) : undefined;
await this.phaseRepository.update(p.id, { name: p.title, content: tiptapContent });
const updated = await this.phaseRepository.findById(p.id);
entries.push({ entityType: 'phase', entityId: p.id, action: 'update', previousState, newState: JSON.stringify(updated), sortOrder: sortOrd++ });
} else if (action === 'delete') {
const existing = await this.phaseRepository.findById(p.id);
if (!existing) continue;
const previousState = JSON.stringify(existing);
await this.phaseRepository.delete(p.id);
entries.push({ entityType: 'phase', entityId: p.id, action: 'delete', previousState, sortOrder: sortOrd++ });
}
} catch (err) {
log.warn({ agentId, phase: p.title, action: p.action, err: err instanceof Error ? err.message : String(err) }, 'failed to process chat phase');
}
}
}
// Process tasks — two pass (create first, then deps)
if (this.taskRepository) {
const fileIdToDbId = new Map<string, string>();
for (const t of chatTasks) {
try {
const action = t.action ?? 'create';
if (action === 'create') {
const created = await this.taskRepository.create({
initiativeId: initiativeId!,
phaseId: t.phaseId ?? null,
parentTaskId: t.parentTaskId ?? null,
name: t.title,
description: t.body ?? undefined,
category: (t.category as any) ?? 'execute',
type: (t.type as any) ?? 'auto',
});
fileIdToDbId.set(t.id, created.id);
entries.push({ entityType: 'task', entityId: created.id, action: 'create', newState: JSON.stringify(created), sortOrder: sortOrd++ });
} else if (action === 'update') {
const existing = await this.taskRepository.findById(t.id);
if (!existing) continue;
const previousState = JSON.stringify(existing);
await this.taskRepository.update(t.id, {
name: t.title,
description: t.body ?? undefined,
category: (t.category as any) ?? existing.category,
type: (t.type as any) ?? existing.type,
});
const updated = await this.taskRepository.findById(t.id);
fileIdToDbId.set(t.id, t.id);
entries.push({ entityType: 'task', entityId: t.id, action: 'update', previousState, newState: JSON.stringify(updated), sortOrder: sortOrd++ });
} else if (action === 'delete') {
const existing = await this.taskRepository.findById(t.id);
if (!existing) continue;
const previousState = JSON.stringify(existing);
await this.taskRepository.delete(t.id);
entries.push({ entityType: 'task', entityId: t.id, action: 'delete', previousState, sortOrder: sortOrd++ });
}
} catch (err) {
log.warn({ agentId, task: t.title, action: t.action, err: err instanceof Error ? err.message : String(err) }, 'failed to process chat task');
}
}
// Second pass: deps for created tasks
for (const t of chatTasks) {
if (t.action !== 'create' || t.dependencies.length === 0) continue;
const taskDbId = fileIdToDbId.get(t.id);
if (!taskDbId) continue;
for (const depFileId of t.dependencies) {
const depDbId = fileIdToDbId.get(depFileId) ?? depFileId;
try {
await this.taskRepository.createDependency(taskDbId, depDbId);
entries.push({ entityType: 'task_dependency', entityId: `${taskDbId}:${depDbId}`, action: 'create', newState: JSON.stringify({ taskId: taskDbId, dependsOnTaskId: depDbId }), sortOrder: sortOrd++ });
} catch (err) {
log.warn({ agentId, taskDbId, depFileId, err: err instanceof Error ? err.message : String(err) }, 'failed to create chat task dependency');
}
}
}
}
// Process pages
if (this.pageRepository) {
for (const p of chatPages) {
try {
const action = p.action ?? 'create';
if (action === 'create') {
const tiptapJson = markdownToTiptapJson(p.body || '');
const created = await this.pageRepository.create({
id: p.pageId ?? undefined,
initiativeId: initiativeId!,
title: p.title,
content: JSON.stringify(tiptapJson),
});
entries.push({ entityType: 'page', entityId: created.id, action: 'create', newState: JSON.stringify(created), sortOrder: sortOrd++ });
} else if (action === 'update') {
const existing = await this.pageRepository.findById(p.pageId);
if (!existing) continue;
const previousState = JSON.stringify(existing);
const tiptapJson = markdownToTiptapJson(p.body || '');
await this.pageRepository.update(p.pageId, { content: JSON.stringify(tiptapJson), title: p.title });
const updated = await this.pageRepository.findById(p.pageId);
entries.push({ entityType: 'page', entityId: p.pageId, action: 'update', previousState, newState: JSON.stringify(updated), sortOrder: sortOrd++ });
} else if (action === 'delete') {
const existing = await this.pageRepository.findById(p.pageId);
if (!existing) continue;
const previousState = JSON.stringify(existing);
await this.pageRepository.delete(p.pageId);
entries.push({ entityType: 'page', entityId: p.pageId, action: 'delete', previousState, sortOrder: sortOrd++ });
}
} catch (err) {
log.warn({ agentId, pageId: p.pageId, action: p.action, err: err instanceof Error ? err.message : String(err) }, 'failed to process chat page');
}
}
}
// Create change set
let changeSetId: string | null = null;
if (entries.length > 0) {
try {
const cs = await this.changeSetRepository!.createWithEntries({
agentId,
agentName: agent.name,
initiativeId: initiativeId!,
mode: 'chat' as 'plan' | 'detail' | 'refine',
summary: summary?.body ?? `Chat: ${entries.length} changes applied`,
}, entries);
changeSetId = cs.id;
this.eventBus?.emit({
type: 'changeset:created' as const,
timestamp: new Date(),
payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'chat', entryCount: entries.length },
});
} catch (err) {
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record chat change set');
}
}
// Store assistant message in chat session
if (this.chatSessionRepository) {
try {
const session = await this.chatSessionRepository.findActiveSessionByAgentId(agentId);
if (session) {
const assistantContent = summary?.body ?? `Applied ${entries.length} changes`;
await this.chatSessionRepository.createMessage({
chatSessionId: session.id,
role: 'assistant',
content: assistantContent,
changeSetId,
});
this.eventBus?.emit({
type: 'chat:message_created' as const,
timestamp: new Date(),
payload: { chatSessionId: session.id, role: 'assistant' },
});
}
} catch (err) {
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to store chat assistant message');
}
}
resultMessage = summary?.body ?? `${entries.length} changes applied`;
} else {
resultMessage = JSON.stringify({ summary: summary?.body, phases: chatPhases, tasks: chatTasks, pages: chatPages });
}
break;
}
}
const resultPayload: AgentResult = {
@@ -738,6 +932,7 @@ export class OutputHandler {
case 'plan': return 'plan_complete';
case 'detail': return 'detail_complete';
case 'refine': return 'refine_complete';
case 'chat': return 'chat_complete';
default: return 'task_complete';
}
}

View File

@@ -0,0 +1,71 @@
/**
* Chat mode prompt — iterative conversation for refining phases/tasks/pages.
*/
import { INPUT_FILES, SIGNAL_FORMAT, ID_GENERATION } from './shared.js';
export interface ChatHistoryEntry {
role: 'user' | 'assistant' | 'system';
content: string;
}
export function buildChatPrompt(
targetType: 'phase' | 'task',
chatHistory: ChatHistoryEntry[],
userInstruction: string,
): string {
const historyBlock = chatHistory.length > 0
? `<chat_history>\n${chatHistory.map(m => `[${m.role}]: ${m.content}`).join('\n\n')}\n</chat_history>`
: '';
return `<role>
You are an Architect agent in chat mode. You iteratively refine ${targetType} structure and content through conversation with the user. You do NOT write code.
</role>
${INPUT_FILES}
${ID_GENERATION}
${SIGNAL_FORMAT}
${historyBlock}
<current_instruction>
${userInstruction}
</current_instruction>
<output_format>
Write output files to \`.cw/output/\` with YAML frontmatter including an \`action\` field:
**Phases** — \`.cw/output/phases/{id}.md\`:
- \`action: create\` — new phase. Filename = new ID from \`cw id\`.
- \`action: update\` — modify existing. Filename = existing phase ID.
- \`action: delete\` — remove. Filename = existing phase ID. Body can be empty.
- Frontmatter: \`title\`, \`action\`, \`dependencies\` (array of phase IDs)
- Body: Phase description in markdown
**Tasks** — \`.cw/output/tasks/{id}.md\`:
- \`action: create\` — new task. Filename = new ID from \`cw id\`.
- \`action: update\` — modify existing. Filename = existing task ID.
- \`action: delete\` — remove. Filename = existing task ID. Body can be empty.
- Frontmatter: \`title\`, \`action\`, \`category\`, \`type\`, \`dependencies\` (array of task IDs), \`phaseId\`, \`parentTaskId\`
- Body: Task description in markdown
**Pages** — \`.cw/output/pages/{pageId}.md\`:
- \`action: create\` — new page. Filename = new ID from \`cw id\`.
- \`action: update\` — modify existing. Filename = existing page ID.
- \`action: delete\` — remove. Filename = existing page ID. Body can be empty.
- Frontmatter: \`title\`, \`action\`, \`summary\` (what changed)
- Body: Full replacement markdown content
</output_format>
<summary_file>
After writing output files, write \`.cw/output/SUMMARY.md\` with a brief description of what you changed and why.
</summary_file>
<rules>
- After applying changes, ALWAYS signal "questions" with: \`{ "status": "questions", "questions": [{ "id": "next", "question": "What would you like to do next?" }] }\`
- Only signal "done" when the user explicitly says they are finished
- Only signal "error" for unrecoverable problems
- Apply the minimal set of changes needed for the user's instruction
- Preserve existing entity IDs — only use \`cw id\` for new entities
- When updating, only include changed fields in frontmatter (plus required \`action\` and \`title\`)
</rules>`;
}

View File

@@ -11,4 +11,6 @@ export { buildDiscussPrompt } from './discuss.js';
export { buildPlanPrompt } from './plan.js';
export { buildDetailPrompt } from './detail.js';
export { buildRefinePrompt } from './refine.js';
export { buildChatPrompt } from './chat.js';
export type { ChatHistoryEntry } from './chat.js';
export { buildWorkspaceLayout } from './workspace.js';

View File

@@ -15,7 +15,7 @@ export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' |
* - plan: Plan initiative into phases
* - detail: Detail phase into individual tasks
*/
export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine';
export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat';
/**
* Context data written as input files in agent workdir before spawn.

View File

@@ -20,6 +20,7 @@ import {
DrizzleChangeSetRepository,
DrizzleLogChunkRepository,
DrizzleConversationRepository,
DrizzleChatSessionRepository,
} from './db/index.js';
import type { InitiativeRepository } from './db/repositories/initiative-repository.js';
import type { PhaseRepository } from './db/repositories/phase-repository.js';
@@ -32,6 +33,7 @@ import type { AccountRepository } from './db/repositories/account-repository.js'
import type { ChangeSetRepository } from './db/repositories/change-set-repository.js';
import type { LogChunkRepository } from './db/repositories/log-chunk-repository.js';
import type { ConversationRepository } from './db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from './db/repositories/chat-session-repository.js';
import type { EventBus } from './events/index.js';
import { createEventBus } from './events/index.js';
import { ProcessManager, ProcessRegistry } from './process/index.js';
@@ -56,7 +58,7 @@ import type { ServerContextDeps } from './server/index.js';
// =============================================================================
/**
* All 11 repository ports.
* All 12 repository ports.
*/
export interface Repositories {
initiativeRepository: InitiativeRepository;
@@ -70,10 +72,11 @@ export interface Repositories {
changeSetRepository: ChangeSetRepository;
logChunkRepository: LogChunkRepository;
conversationRepository: ConversationRepository;
chatSessionRepository: ChatSessionRepository;
}
/**
* Create all 11 Drizzle repository adapters from a database instance.
* Create all 12 Drizzle repository adapters from a database instance.
* Reusable by both the production server and the test harness.
*/
export function createRepositories(db: DrizzleDatabase): Repositories {
@@ -89,6 +92,7 @@ export function createRepositories(db: DrizzleDatabase): Repositories {
changeSetRepository: new DrizzleChangeSetRepository(db),
logChunkRepository: new DrizzleLogChunkRepository(db),
conversationRepository: new DrizzleConversationRepository(db),
chatSessionRepository: new DrizzleChatSessionRepository(db),
};
}
@@ -171,6 +175,8 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
repos.pageRepository,
repos.logChunkRepository,
options?.debug ?? false,
undefined, // processManagerOverride
repos.chatSessionRepository,
);
log.info('agent manager created');

View File

@@ -11,7 +11,7 @@ export type CreateChangeSetData = {
agentId: string | null;
agentName: string;
initiativeId: string;
mode: 'plan' | 'detail' | 'refine';
mode: 'plan' | 'detail' | 'refine' | 'chat';
summary?: string | null;
};

View File

@@ -0,0 +1,31 @@
/**
* Chat Session Repository Port Interface
*
* Port for chat session and message persistence operations.
*/
import type { ChatSession, ChatMessage } from '../schema.js';
export interface CreateChatSessionData {
targetType: 'phase' | 'task';
targetId: string;
initiativeId: string;
agentId?: string | null;
}
export interface CreateChatMessageData {
chatSessionId: string;
role: 'user' | 'assistant' | 'system';
content: string;
changeSetId?: string | null;
}
export interface ChatSessionRepository {
createSession(data: CreateChatSessionData): Promise<ChatSession>;
findSessionById(id: string): Promise<ChatSession | null>;
findActiveSession(targetType: 'phase' | 'task', targetId: string): Promise<ChatSession | null>;
findActiveSessionByAgentId(agentId: string): Promise<ChatSession | null>;
updateSession(id: string, data: { agentId?: string | null; status?: 'active' | 'closed' }): Promise<ChatSession>;
createMessage(data: CreateChatMessageData): Promise<ChatMessage>;
findMessagesBySessionId(sessionId: string): Promise<ChatMessage[]>;
}

View File

@@ -0,0 +1,107 @@
/**
* Drizzle Chat Session Repository Adapter
*
* Implements ChatSessionRepository interface using Drizzle ORM.
*/
import { eq, and, asc } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { chatSessions, chatMessages, type ChatSession, type ChatMessage } from '../../schema.js';
import type { ChatSessionRepository, CreateChatSessionData, CreateChatMessageData } from '../chat-session-repository.js';
export class DrizzleChatSessionRepository implements ChatSessionRepository {
constructor(private db: DrizzleDatabase) {}
async createSession(data: CreateChatSessionData): Promise<ChatSession> {
const now = new Date();
const id = nanoid();
await this.db.insert(chatSessions).values({
id,
targetType: data.targetType,
targetId: data.targetId,
initiativeId: data.initiativeId,
agentId: data.agentId ?? null,
status: 'active',
createdAt: now,
updatedAt: now,
});
return this.findSessionById(id) as Promise<ChatSession>;
}
async findSessionById(id: string): Promise<ChatSession | null> {
const rows = await this.db
.select()
.from(chatSessions)
.where(eq(chatSessions.id, id))
.limit(1);
return rows[0] ?? null;
}
async findActiveSession(targetType: 'phase' | 'task', targetId: string): Promise<ChatSession | null> {
const rows = await this.db
.select()
.from(chatSessions)
.where(
and(
eq(chatSessions.targetType, targetType),
eq(chatSessions.targetId, targetId),
eq(chatSessions.status, 'active' as 'active' | 'closed'),
),
)
.limit(1);
return rows[0] ?? null;
}
async findActiveSessionByAgentId(agentId: string): Promise<ChatSession | null> {
const rows = await this.db
.select()
.from(chatSessions)
.where(
and(
eq(chatSessions.agentId, agentId),
eq(chatSessions.status, 'active' as 'active' | 'closed'),
),
)
.limit(1);
return rows[0] ?? null;
}
async updateSession(id: string, data: { agentId?: string | null; status?: 'active' | 'closed' }): Promise<ChatSession> {
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (data.agentId !== undefined) updates.agentId = data.agentId;
if (data.status !== undefined) updates.status = data.status;
await this.db
.update(chatSessions)
.set(updates)
.where(eq(chatSessions.id, id));
return this.findSessionById(id) as Promise<ChatSession>;
}
async createMessage(data: CreateChatMessageData): Promise<ChatMessage> {
const id = nanoid();
const now = new Date();
await this.db.insert(chatMessages).values({
id,
chatSessionId: data.chatSessionId,
role: data.role,
content: data.content,
changeSetId: data.changeSetId ?? null,
createdAt: now,
});
const rows = await this.db
.select()
.from(chatMessages)
.where(eq(chatMessages.id, id))
.limit(1);
return rows[0]!;
}
async findMessagesBySessionId(sessionId: string): Promise<ChatMessage[]> {
return this.db
.select()
.from(chatMessages)
.where(eq(chatMessages.chatSessionId, sessionId))
.orderBy(asc(chatMessages.createdAt));
}
}

View File

@@ -16,3 +16,4 @@ export { DrizzleAccountRepository } from './account.js';
export { DrizzleChangeSetRepository } from './change-set.js';
export { DrizzleLogChunkRepository } from './log-chunk.js';
export { DrizzleConversationRepository } from './conversation.js';
export { DrizzleChatSessionRepository } from './chat-session.js';

View File

@@ -18,7 +18,7 @@ export class DrizzlePageRepository implements PageRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreatePageData): Promise<Page> {
const id = nanoid();
const id = data.id ?? nanoid();
const now = new Date();
const [created] = await this.db.insert(pages).values({

View File

@@ -25,7 +25,7 @@ export class DrizzleTaskRepository implements TaskRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateTaskData): Promise<Task> {
const id = nanoid();
const id = data.id ?? nanoid();
const now = new Date();
const [created] = await this.db.insert(tasks).values({

View File

@@ -72,3 +72,9 @@ export type {
ConversationRepository,
CreateConversationData,
} from './conversation-repository.js';
export type {
ChatSessionRepository,
CreateChatSessionData,
CreateChatMessageData,
} from './chat-session-repository.js';

View File

@@ -11,7 +11,7 @@ import type { Page, NewPage } from '../schema.js';
* Data for creating a new page.
* Omits system-managed fields (id, createdAt, updatedAt).
*/
export type CreatePageData = Omit<NewPage, 'id' | 'createdAt' | 'updatedAt'>;
export type CreatePageData = Omit<NewPage, 'id' | 'createdAt' | 'updatedAt'> & { id?: string };
/**
* Data for updating a page.

View File

@@ -12,7 +12,7 @@ import type { Task, NewTask, TaskCategory } from '../schema.js';
* Omits system-managed fields (id, createdAt, updatedAt).
* At least one of phaseId, initiativeId, or parentTaskId should be provided.
*/
export type CreateTaskData = Omit<NewTask, 'id' | 'createdAt' | 'updatedAt'>;
export type CreateTaskData = Omit<NewTask, 'id' | 'createdAt' | 'updatedAt'> & { id?: string };
/**
* Data for updating a task.

View File

@@ -264,7 +264,7 @@ export const agents = sqliteTable('agents', {
})
.notNull()
.default('idle'),
mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine'] })
mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat'] })
.notNull()
.default('execute'),
pid: integer('pid'),
@@ -308,7 +308,7 @@ export const changeSets = sqliteTable('change_sets', {
initiativeId: text('initiative_id')
.notNull()
.references(() => initiatives.id, { onDelete: 'cascade' }),
mode: text('mode', { enum: ['plan', 'detail', 'refine'] }).notNull(),
mode: text('mode', { enum: ['plan', 'detail', 'refine', 'chat'] }).notNull(),
summary: text('summary'),
status: text('status', { enum: ['applied', 'reverted'] })
.notNull()
@@ -536,3 +536,71 @@ export const conversations = sqliteTable('conversations', {
export type Conversation = InferSelectModel<typeof conversations>;
export type NewConversation = InferInsertModel<typeof conversations>;
// ============================================================================
// CHAT SESSIONS
// ============================================================================
export const chatSessions = sqliteTable('chat_sessions', {
id: text('id').primaryKey(),
targetType: text('target_type', { enum: ['phase', 'task'] }).notNull(),
targetId: text('target_id').notNull(),
initiativeId: text('initiative_id')
.notNull()
.references(() => initiatives.id, { onDelete: 'cascade' }),
agentId: text('agent_id').references(() => agents.id, { onDelete: 'set null' }),
status: text('status', { enum: ['active', 'closed'] })
.notNull()
.default('active'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => [
index('chat_sessions_target_idx').on(table.targetType, table.targetId),
index('chat_sessions_initiative_id_idx').on(table.initiativeId),
]);
export const chatSessionsRelations = relations(chatSessions, ({ one, many }) => ({
initiative: one(initiatives, {
fields: [chatSessions.initiativeId],
references: [initiatives.id],
}),
agent: one(agents, {
fields: [chatSessions.agentId],
references: [agents.id],
}),
messages: many(chatMessages),
}));
export type ChatSession = InferSelectModel<typeof chatSessions>;
export type NewChatSession = InferInsertModel<typeof chatSessions>;
// ============================================================================
// CHAT MESSAGES
// ============================================================================
export const chatMessages = sqliteTable('chat_messages', {
id: text('id').primaryKey(),
chatSessionId: text('chat_session_id')
.notNull()
.references(() => chatSessions.id, { onDelete: 'cascade' }),
role: text('role', { enum: ['user', 'assistant', 'system'] }).notNull(),
content: text('content').notNull(),
changeSetId: text('change_set_id').references(() => changeSets.id, { onDelete: 'set null' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
}, (table) => [
index('chat_messages_session_id_idx').on(table.chatSessionId),
]);
export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
chatSession: one(chatSessions, {
fields: [chatMessages.chatSessionId],
references: [chatSessions.id],
}),
changeSet: one(changeSets, {
fields: [chatMessages.changeSetId],
references: [changeSets.id],
}),
}));
export type ChatMessage = InferSelectModel<typeof chatMessages>;
export type NewChatMessage = InferInsertModel<typeof chatMessages>;

View File

@@ -0,0 +1,26 @@
-- Chat sessions and messages for iterative phase/task refinement
CREATE TABLE `chat_sessions` (
`id` text PRIMARY KEY NOT NULL,
`target_type` text NOT NULL,
`target_id` text NOT NULL,
`initiative_id` text NOT NULL REFERENCES `initiatives`(`id`) ON DELETE CASCADE,
`agent_id` text REFERENCES `agents`(`id`) ON DELETE SET NULL,
`status` text DEFAULT 'active' NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `chat_sessions_target_idx` ON `chat_sessions` (`target_type`, `target_id`);
--> statement-breakpoint
CREATE INDEX `chat_sessions_initiative_id_idx` ON `chat_sessions` (`initiative_id`);
--> statement-breakpoint
CREATE TABLE `chat_messages` (
`id` text PRIMARY KEY NOT NULL,
`chat_session_id` text NOT NULL REFERENCES `chat_sessions`(`id`) ON DELETE CASCADE,
`role` text NOT NULL,
`content` text NOT NULL,
`change_set_id` text REFERENCES `change_sets`(`id`) ON DELETE SET NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `chat_messages_session_id_idx` ON `chat_messages` (`chat_session_id`);

View File

@@ -190,6 +190,13 @@
"when": 1771804800000,
"tag": "0026_add_task_summary",
"breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1771891200000,
"tag": "0027_add_chat_sessions",
"breakpoints": true
}
]
}

View File

@@ -162,7 +162,8 @@ export interface AgentStoppedEvent extends DomainEvent {
| 'context_complete'
| 'plan_complete'
| 'detail_complete'
| 'refine_complete';
| 'refine_complete'
| 'chat_complete';
};
}
@@ -542,6 +543,26 @@ export interface ConversationAnsweredEvent extends DomainEvent {
};
}
/**
* Chat Session Events
*/
export interface ChatMessageCreatedEvent extends DomainEvent {
type: 'chat:message_created';
payload: {
chatSessionId: string;
role: 'user' | 'assistant' | 'system';
};
}
export interface ChatSessionClosedEvent extends DomainEvent {
type: 'chat:session_closed';
payload: {
chatSessionId: string;
initiativeId: string;
};
}
/**
* Union of all domain events - enables type-safe event handling
*/
@@ -593,7 +614,9 @@ export type DomainEventMap =
| PreviewStoppedEvent
| PreviewFailedEvent
| ConversationCreatedEvent
| ConversationAnsweredEvent;
| ConversationAnsweredEvent
| ChatMessageCreatedEvent
| ChatSessionClosedEvent;
/**
* Event type literal union for type checking

View File

@@ -17,6 +17,7 @@ import type { AccountRepository } from '../db/repositories/account-repository.js
import type { ChangeSetRepository } from '../db/repositories/change-set-repository.js';
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
import type { ConversationRepository } from '../db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-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';
@@ -73,6 +74,8 @@ export interface TRPCContext {
previewManager?: PreviewManager;
/** Conversation repository for inter-agent communication */
conversationRepository?: ConversationRepository;
/** Chat session repository for iterative phase/task chat */
chatSessionRepository?: ChatSessionRepository;
/** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string;
}
@@ -102,6 +105,7 @@ export interface CreateContextOptions {
executionOrchestrator?: ExecutionOrchestrator;
previewManager?: PreviewManager;
conversationRepository?: ConversationRepository;
chatSessionRepository?: ChatSessionRepository;
workspaceRoot?: string;
}
@@ -134,6 +138,7 @@ export function createContext(options: CreateContextOptions): TRPCContext {
executionOrchestrator: options.executionOrchestrator,
previewManager: options.previewManager,
conversationRepository: options.conversationRepository,
chatSessionRepository: options.chatSessionRepository,
workspaceRoot: options.workspaceRoot,
};
}

View File

@@ -23,6 +23,7 @@ import { changeSetProcedures } from './routers/change-set.js';
import { subscriptionProcedures } from './routers/subscription.js';
import { previewProcedures } from './routers/preview.js';
import { conversationProcedures } from './routers/conversation.js';
import { chatSessionProcedures } from './routers/chat-session.js';
// Re-export tRPC primitives (preserves existing import paths)
export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js';
@@ -61,6 +62,7 @@ export const appRouter = router({
...subscriptionProcedures(publicProcedure),
...previewProcedures(publicProcedure),
...conversationProcedures(publicProcedure),
...chatSessionProcedures(publicProcedure),
});
export type AppRouter = typeof appRouter;

View File

@@ -17,6 +17,7 @@ import type { AccountRepository } from '../../db/repositories/account-repository
import type { ChangeSetRepository } from '../../db/repositories/change-set-repository.js';
import type { LogChunkRepository } from '../../db/repositories/log-chunk-repository.js';
import type { ConversationRepository } from '../../db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from '../../db/repositories/chat-session-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';
@@ -192,3 +193,13 @@ export function requireConversationRepository(ctx: TRPCContext): ConversationRep
}
return ctx.conversationRepository;
}
export function requireChatSessionRepository(ctx: TRPCContext): ChatSessionRepository {
if (!ctx.chatSessionRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Chat session repository not available',
});
}
return ctx.chatSessionRepository;
}

View File

@@ -25,7 +25,7 @@ import type { PageRepository } from '../../db/repositories/page-repository.js';
import type { Phase, Task } from '../../db/schema.js';
import type { PageForSerialization } from '../../agent/content-serializer.js';
async function gatherInitiativeContext(
export async function gatherInitiativeContext(
phaseRepo: PhaseRepository | undefined,
taskRepo: TaskRepository | undefined,
pageRepo: PageRepository | undefined,

View File

@@ -114,6 +114,20 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
} else if (entry.action === 'update' && entry.previousState) {
const prev = JSON.parse(entry.previousState);
switch (entry.entityType) {
case 'phase':
await phaseRepo.update(entry.entityId, {
name: prev.name,
content: prev.content,
});
break;
case 'task':
await taskRepo.update(entry.entityId, {
name: prev.name,
description: prev.description,
category: prev.category,
type: prev.type,
});
break;
case 'page':
await pageRepo.update(entry.entityId, {
content: prev.content,
@@ -126,6 +140,19 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
});
break;
}
} else if (entry.action === 'delete' && entry.previousState) {
const prev = JSON.parse(entry.previousState);
switch (entry.entityType) {
case 'phase':
try { await phaseRepo.create({ id: prev.id, initiativeId: prev.initiativeId, name: prev.name, content: prev.content }); } catch { /* already exists */ }
break;
case 'task':
try { await taskRepo.create({ id: prev.id, initiativeId: prev.initiativeId, phaseId: prev.phaseId, parentTaskId: prev.parentTaskId, name: prev.name, description: prev.description, category: prev.category, type: prev.type }); } catch { /* already exists */ }
break;
case 'page':
try { await pageRepo.create({ id: prev.id, initiativeId: prev.initiativeId, parentPageId: prev.parentPageId, title: prev.title, content: prev.content }); } catch { /* already exists */ }
break;
}
}
} catch (err) {
// Log but continue reverting other entries

View File

@@ -0,0 +1,187 @@
/**
* Chat Session Router — send messages, get session, close session
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import {
requireAgentManager,
requireInitiativeRepository,
requirePhaseRepository,
requirePageRepository,
requireTaskRepository,
requireChatSessionRepository,
} from './_helpers.js';
import { gatherInitiativeContext } from './architect.js';
import { buildChatPrompt, type ChatHistoryEntry } from '../../agent/prompts/index.js';
const MAX_HISTORY_MESSAGES = 30;
export function chatSessionProcedures(publicProcedure: ProcedureBuilder) {
return {
sendChatMessage: publicProcedure
.input(z.object({
targetType: z.enum(['phase', 'task']),
targetId: z.string().min(1),
initiativeId: z.string().min(1),
message: z.string().min(1),
provider: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const chatRepo = requireChatSessionRepository(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` });
}
// Find or create active session
let session = await chatRepo.findActiveSession(input.targetType, input.targetId);
if (!session) {
session = await chatRepo.createSession({
targetType: input.targetType,
targetId: input.targetId,
initiativeId: input.initiativeId,
});
}
// Store user message
await chatRepo.createMessage({
chatSessionId: session.id,
role: 'user',
content: input.message,
});
ctx.eventBus.emit({
type: 'chat:message_created' as const,
timestamp: new Date(),
payload: { chatSessionId: session.id, role: 'user' as const },
});
// Check if agent exists and is waiting for input
if (session.agentId) {
const agent = await agentManager.get(session.agentId);
if (agent && agent.status === 'waiting_for_input') {
// Resume the existing agent
await agentManager.resume(agent.id, { 'chat-response': input.message });
return { sessionId: session.id, agentId: agent.id, action: 'resumed' as const };
}
// Agent exists but not waiting — dismiss it
if (agent && !agent.userDismissedAt) {
if (['running'].includes(agent.status)) {
await agentManager.stop(agent.id);
}
await agentManager.dismiss(agent.id);
}
}
// Spawn fresh agent with chat history + context
const messages = await chatRepo.findMessagesBySessionId(session.id);
const chatHistory: ChatHistoryEntry[] = messages
.slice(-MAX_HISTORY_MESSAGES)
.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }));
const context = await gatherInitiativeContext(
ctx.phaseRepository,
ctx.taskRepository,
ctx.pageRepository,
input.initiativeId,
);
const prompt = buildChatPrompt(input.targetType, chatHistory, input.message);
// Create a task for the chat agent
const targetName = input.targetType === 'phase'
? (await ctx.phaseRepository?.findById(input.targetId))?.name ?? input.targetId
: (await ctx.taskRepository?.findById(input.targetId))?.name ?? input.targetId;
const task = await taskRepo.create({
initiativeId: input.initiativeId,
name: `Chat: ${targetName}`,
description: `Iterative chat refinement of ${input.targetType}`,
category: 'discuss',
status: 'in_progress',
});
// Determine target phase/task for input context
const targetPhase = input.targetType === 'phase'
? await ctx.phaseRepository?.findById(input.targetId)
: undefined;
const targetTask = input.targetType === 'task'
? await ctx.taskRepository?.findById(input.targetId)
: undefined;
const agent = await agentManager.spawn({
taskId: task.id,
prompt,
mode: 'chat',
provider: input.provider,
initiativeId: input.initiativeId,
inputContext: {
initiative,
phase: targetPhase ?? undefined,
task: targetTask ?? undefined,
pages: context.pages.length > 0 ? context.pages : undefined,
phases: context.phases.length > 0 ? context.phases : undefined,
tasks: context.tasks.length > 0 ? context.tasks : undefined,
},
});
// Link agent to session
await chatRepo.updateSession(session.id, { agentId: agent.id });
return { sessionId: session.id, agentId: agent.id, action: 'spawned' as const };
}),
getChatSession: publicProcedure
.input(z.object({
targetType: z.enum(['phase', 'task']),
targetId: z.string().min(1),
}))
.query(async ({ ctx, input }) => {
const chatRepo = requireChatSessionRepository(ctx);
const session = await chatRepo.findActiveSession(input.targetType, input.targetId);
if (!session) return null;
const messages = await chatRepo.findMessagesBySessionId(session.id);
return { ...session, messages };
}),
closeChatSession: publicProcedure
.input(z.object({ sessionId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const chatRepo = requireChatSessionRepository(ctx);
const agentManager = requireAgentManager(ctx);
const session = await chatRepo.findSessionById(input.sessionId);
if (!session) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Chat session '${input.sessionId}' not found` });
}
// Stop and dismiss agent if active
if (session.agentId) {
const agent = await agentManager.get(session.agentId);
if (agent && !agent.userDismissedAt) {
if (['running', 'waiting_for_input'].includes(agent.status)) {
await agentManager.stop(agent.id);
}
await agentManager.dismiss(agent.id);
}
}
await chatRepo.updateSession(input.sessionId, { status: 'closed' });
ctx.eventBus.emit({
type: 'chat:session_closed' as const,
timestamp: new Date(),
payload: { chatSessionId: session.id, initiativeId: session.initiativeId },
});
return { success: true };
}),
};
}

View File

@@ -63,6 +63,8 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
'changeset:reverted',
'conversation:created',
'conversation:answered',
'chat:message_created',
'chat:session_closed',
];
/**

View File

@@ -14,6 +14,7 @@ import {
PhaseDetailEmpty,
} from "@/components/execution/PhaseDetailPanel";
import { TaskSlideOver } from "@/components/execution/TaskSlideOver";
import { ChatSlideOver, type ChatTarget } from "@/components/chat/ChatSlideOver";
import { Skeleton } from "@/components/Skeleton";
interface ExecutionTabProps {
@@ -51,6 +52,7 @@ export function ExecutionTab({
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
const [isAddingPhase, setIsAddingPhase] = useState(false);
const [chatTarget, setChatTarget] = useState<ChatTarget | null>(null);
const deletePhase = trpc.deletePhase.useMutation({
onSuccess: () => {
@@ -180,7 +182,12 @@ export function ExecutionTab({
phases={sortedPhases}
onAddPhase={handleStartAdd}
/>
<TaskSlideOver />
<TaskSlideOver onOpenChat={(target) => setChatTarget(target)} />
<ChatSlideOver
target={chatTarget}
initiativeId={initiativeId}
onClose={() => setChatTarget(null)}
/>
</ExecutionProvider>
);
}
@@ -248,13 +255,19 @@ export function ExecutionTab({
onDelete={() => deletePhase.mutate({ id: activePhase.id })}
detailAgent={detailAgentByPhase.get(activePhase.id) ?? null}
branch={branch}
onOpenChat={(target) => setChatTarget(target)}
/>
) : (
<PhaseDetailEmpty />
)}
</div>
</div>
<TaskSlideOver />
<TaskSlideOver onOpenChat={(target) => setChatTarget(target)} />
<ChatSlideOver
target={chatTarget}
initiativeId={initiativeId}
onClose={() => setChatTarget(null)}
/>
</ExecutionProvider>
);
}

View File

@@ -0,0 +1,112 @@
import { useState, useCallback } from 'react';
import { Undo2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { trpc } from '@/lib/trpc';
interface ChangeSetInlineProps {
changeSetId: string;
}
export function ChangeSetInline({ changeSetId }: ChangeSetInlineProps) {
const [conflicts, setConflicts] = useState<string[] | null>(null);
const detailQuery = trpc.getChangeSet.useQuery({ id: changeSetId });
const entries = detailQuery.data?.entries ?? [];
const status = detailQuery.data?.status ?? 'applied';
const isReverted = status === 'reverted';
const revertMutation = trpc.revertChangeSet.useMutation({
onSuccess: (result) => {
if (!result.success && 'conflicts' in result) {
setConflicts(result.conflicts);
} else {
setConflicts(null);
}
},
});
const handleRevert = useCallback(
(force?: boolean) => {
revertMutation.mutate({ id: changeSetId, force });
},
[changeSetId, revertMutation],
);
if (detailQuery.isLoading) {
return (
<div className="mt-1.5 rounded border border-border bg-accent/30 px-2.5 py-1.5 text-xs text-muted-foreground">
Loading changes...
</div>
);
}
if (entries.length === 0) return null;
return (
<div className="mt-1.5 rounded border border-border bg-accent/30 px-2.5 py-1.5 space-y-1.5">
<div className="space-y-0.5">
{entries.map((entry) => (
<div
key={entry.id}
className={`flex items-center gap-1.5 text-xs ${isReverted ? 'line-through text-muted-foreground/60' : 'text-muted-foreground'}`}
>
<span className="font-mono">
{entry.action === 'create' ? '+' : entry.action === 'delete' ? '-' : '~'}
</span>
<span>
{entry.action === 'create' ? 'Created' : entry.action === 'delete' ? 'Deleted' : 'Updated'}{' '}
{entry.entityType}
{entry.newState &&
(() => {
try {
const parsed = JSON.parse(entry.newState);
return parsed.name || parsed.title ? `: ${parsed.name || parsed.title}` : '';
} catch {
return '';
}
})()}
</span>
</div>
))}
</div>
{isReverted ? (
<span className="text-[10px] font-medium text-muted-foreground italic">Reverted</span>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handleRevert()}
disabled={revertMutation.isPending}
className="h-6 gap-1 px-1.5 text-[10px]"
>
<Undo2 className="h-3 w-3" />
{revertMutation.isPending ? 'Reverting...' : 'Revert'}
</Button>
)}
{conflicts && (
<div className="rounded border border-status-warning-border bg-status-warning-bg p-1.5">
<p className="text-[10px] font-medium text-status-warning-fg">Conflicts:</p>
<ul className="text-[10px] text-status-warning-fg list-disc pl-3">
{conflicts.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
<Button
variant="outline"
size="sm"
onClick={() => {
setConflicts(null);
handleRevert(true);
}}
disabled={revertMutation.isPending}
className="mt-1 h-5 text-[10px]"
>
Force Revert
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { cn } from '@/lib/utils';
import { ChangeSetInline } from './ChangeSetInline';
import type { ChatMessage } from '@/hooks/useChatSession';
interface ChatBubbleProps {
message: ChatMessage;
}
export function ChatBubble({ message }: ChatBubbleProps) {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
if (isSystem) {
return (
<div className="flex justify-center px-4 py-1">
<span className="text-xs text-muted-foreground italic">{message.content}</span>
</div>
);
}
return (
<div className={cn('flex px-4 py-1.5', isUser ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[85%] rounded-lg px-3 py-2 text-sm',
isUser
? 'bg-primary/10 text-foreground'
: 'bg-card border border-border text-foreground',
)}
>
<p className="whitespace-pre-wrap">{message.content}</p>
{message.changeSetId && <ChangeSetInline changeSetId={message.changeSetId} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ChatInputProps {
onSend: (message: string) => void;
disabled?: boolean;
placeholder?: string;
}
export function ChatInput({ onSend, disabled, placeholder }: ChatInputProps) {
const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
}, [value]);
const handleSend = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue('');
}, [value, disabled, onSend]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
return (
<div className="flex items-end gap-2 border-t border-border px-4 py-3">
<textarea
ref={textareaRef}
className="min-h-[36px] max-h-[120px] flex-1 resize-none rounded-md border border-border bg-background px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus:border-primary"
placeholder={placeholder ?? 'Send a message...'}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
rows={1}
/>
<Button
size="sm"
onClick={handleSend}
disabled={disabled || !value.trim()}
className="shrink-0"
>
<Send className="h-3.5 w-3.5" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { X, Loader2 } from 'lucide-react';
import { useChatSession } from '@/hooks/useChatSession';
import { ChatBubble } from './ChatBubble';
import { ChatInput } from './ChatInput';
export interface ChatTarget {
type: 'phase' | 'task';
id: string;
name: string;
}
interface ChatSlideOverProps {
target: ChatTarget | null;
initiativeId: string;
onClose: () => void;
}
export function ChatSlideOver({ target, initiativeId, onClose }: ChatSlideOverProps) {
return (
<AnimatePresence>
{target && (
<ChatSlideOverInner
key={`${target.type}-${target.id}`}
target={target}
initiativeId={initiativeId}
onClose={onClose}
/>
)}
</AnimatePresence>
);
}
function ChatSlideOverInner({
target,
initiativeId,
onClose,
}: {
target: ChatTarget;
initiativeId: string;
onClose: () => void;
}) {
const { messages, agentStatus, sendMessage, closeSession, isSending } =
useChatSession(target.type, target.id, initiativeId);
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll on new messages
useEffect(() => {
const el = scrollRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, [messages.length]);
// Escape to close
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onClose]);
const isAgentWorking = agentStatus === 'running' || isSending;
function handleClose() {
closeSession();
onClose();
}
return (
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 z-40 bg-background/60 backdrop-blur-[2px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
/>
{/* Panel */}
<motion.div
className="fixed inset-y-0 right-0 z-50 flex w-full max-w-2xl flex-col border-l border-border bg-background shadow-xl"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.25, ease: [0, 0, 0.2, 1] }}
>
{/* Header */}
<div className="flex items-center gap-3 border-b border-border px-5 py-3">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold leading-snug">
Chat: {target.name}
</h3>
<p className="text-xs text-muted-foreground capitalize">{target.type}</p>
</div>
{isAgentWorking && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Processing...
</div>
)}
<button
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={handleClose}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto py-3">
{messages.length === 0 && (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Send a message to start refining this {target.type}
</div>
)}
{messages.map((msg) => (
<ChatBubble key={msg.id} message={msg} />
))}
{isAgentWorking && messages.length > 0 && (
<div className="flex justify-start px-4 py-1.5">
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Thinking...
</div>
</div>
)}
</div>
{/* Input */}
<ChatInput
onSend={sendMessage}
disabled={isAgentWorking}
placeholder={`Tell the agent what to change...`}
/>
</motion.div>
</>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { GitBranch, Loader2, MoreHorizontal, Plus, Sparkles, Trash2, X } from "lucide-react";
import { GitBranch, Loader2, MessageCircle, MoreHorizontal, Plus, Sparkles, Trash2, X } from "lucide-react";
import type { ChatTarget } from "@/components/chat/ChatSlideOver";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { StatusBadge } from "@/components/StatusBadge";
@@ -40,6 +41,7 @@ interface PhaseDetailPanelProps {
tasksLoading: boolean;
onDelete?: () => void;
branch?: string | null;
onOpenChat?: (target: ChatTarget) => void;
detailAgent: {
id: string;
status: string;
@@ -57,6 +59,7 @@ export function PhaseDetailPanel({
tasksLoading,
onDelete,
branch,
onOpenChat,
detailAgent,
}: PhaseDetailPanelProps) {
const { setSelectedTaskId, handleTaskCounts, handleRegisterTasks } =
@@ -258,6 +261,17 @@ export function PhaseDetailPanel({
</Button>
)}
{/* Chat button */}
<Button
variant="outline"
size="sm"
onClick={() => onOpenChat?.({ type: 'phase', id: phase.id, name: phase.name })}
className="gap-1.5"
>
<MessageCircle className="h-3.5 w-3.5" />
Chat
</Button>
{/* Running indicator in header */}
{isDetailRunning && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, Trash2 } from "lucide-react";
import { X, Trash2, MessageCircle } from "lucide-react";
import type { ChatTarget } from "@/components/chat/ChatSlideOver";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge";
@@ -12,7 +13,11 @@ import { useExecutionContext } from "./ExecutionContext";
import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils";
export function TaskSlideOver() {
interface TaskSlideOverProps {
onOpenChat?: (target: ChatTarget) => void;
}
export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
const queueTaskMutation = trpc.queueTask.useMutation();
const deleteTaskMutation = trpc.deleteTask.useMutation();
@@ -235,6 +240,18 @@ export function TaskSlideOver() {
>
Queue Task
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => {
onOpenChat?.({ type: 'task', id: task.id, name: task.name });
close();
}}
>
<MessageCircle className="h-3.5 w-3.5" />
Chat
</Button>
<Button
variant="destructive"
size="sm"

View File

@@ -0,0 +1,137 @@
import { useCallback, useMemo, useRef } from 'react';
import { trpc } from '@/lib/trpc';
import { useLiveUpdates } from './useLiveUpdates';
export type ChatAgentStatus = 'idle' | 'running' | 'waiting' | 'none';
export interface ChatMessage {
id: string;
chatSessionId: string;
role: 'user' | 'assistant' | 'system';
content: string;
changeSetId: string | null;
createdAt: string | Date;
}
export interface ChatSession {
id: string;
targetType: 'phase' | 'task';
targetId: string;
initiativeId: string;
agentId: string | null;
status: 'active' | 'closed';
messages: ChatMessage[];
}
export interface UseChatSessionResult {
session: ChatSession | null;
messages: ChatMessage[];
agentStatus: ChatAgentStatus;
sendMessage: (message: string) => void;
closeSession: () => void;
isSending: boolean;
isLoading: boolean;
}
/**
* Hook for managing a chat session with a phase or task.
*
* Queries the active chat session, tracks agent status,
* and provides mutations for sending messages and closing.
*/
export function useChatSession(
targetType: 'phase' | 'task',
targetId: string,
initiativeId: string,
): UseChatSessionResult {
const utils = trpc.useUtils();
// Live updates for chat + agent events
useLiveUpdates([
{ prefix: 'chat:', invalidate: ['getChatSession'] },
{ prefix: 'agent:', invalidate: ['getChatSession'] },
{ prefix: 'changeset:', invalidate: ['getChatSession'] },
]);
// Query active session
const sessionQuery = trpc.getChatSession.useQuery(
{ targetType, targetId },
{ enabled: !!targetId },
);
const session = (sessionQuery.data as ChatSession | null) ?? null;
const messages = session?.messages ?? [];
// Query agent status if session has an agent
const agentQuery = trpc.getAgent.useQuery(
{ id: session?.agentId ?? '' },
{ enabled: !!session?.agentId },
);
const agentStatus: ChatAgentStatus = useMemo(() => {
if (!session?.agentId || !agentQuery.data) return 'none';
const status = agentQuery.data.status;
switch (status) {
case 'running':
return 'running';
case 'waiting_for_input':
return 'waiting';
case 'idle':
return 'idle';
default:
return 'none';
}
}, [session?.agentId, agentQuery.data]);
// Send message mutation
const sendMutation = trpc.sendChatMessage.useMutation({
onSuccess: () => {
void utils.getChatSession.invalidate({ targetType, targetId });
},
});
// Close session mutation
const closeMutation = trpc.closeChatSession.useMutation({
onSuccess: () => {
void utils.getChatSession.invalidate({ targetType, targetId });
},
});
const sendMutateRef = useRef(sendMutation.mutate);
sendMutateRef.current = sendMutation.mutate;
const closeMutateRef = useRef(closeMutation.mutate);
closeMutateRef.current = closeMutation.mutate;
const sessionRef = useRef(session);
sessionRef.current = session;
const sendMessage = useCallback(
(message: string) => {
sendMutateRef.current({
targetType,
targetId,
initiativeId,
message,
});
},
[targetType, targetId, initiativeId],
);
const closeSession = useCallback(() => {
const s = sessionRef.current;
if (s) {
closeMutateRef.current({ sessionId: s.id });
}
}, []);
const isLoading = sessionQuery.isLoading;
const isSending = sendMutation.isPending;
return {
session,
messages,
agentStatus,
sendMessage,
closeSession,
isSending,
isLoading,
};
}

View File

@@ -11,7 +11,7 @@
| `process-manager.ts` | `AgentProcessManager` — worktree creation, command building, detached spawn |
| `output-handler.ts` | `OutputHandler` — JSONL stream parsing, completion detection, proposal creation, task dedup, task dependency persistence |
| `file-tailer.ts` | `FileTailer` — watches output files, fires parser + raw content callbacks |
| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion |
| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion. Output files support `action` field (create/update/delete) for chat mode CRUD. |
| `markdown-to-tiptap.ts` | Markdown to Tiptap JSON conversion using MarkdownManager |
| `index.ts` | Public exports, `ClaudeAgentManager` deprecated alias |
@@ -24,7 +24,7 @@
| `accounts/` | Account discovery, config dir setup, credential management, usage API |
| `credentials/` | `AccountCredentialManager` — credential injection per account |
| `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions |
| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
## Key Flows
@@ -239,6 +239,7 @@ All prompts follow a consistent tag ordering:
| **detail** | `detail.ts` | `<task_body_requirements>`, `<file_ownership>`, `<task_sizing>`, `<checkpoint_tasks>`, `<existing_context>` |
| **discuss** | `discuss.ts` | `<analysis_method>`, `<question_quality>`, `<decision_quality>`, `<question_categories>`, `<rules>` |
| **refine** | `refine.ts` | `<improvement_priorities>`, `<rules>` |
| **chat** | `chat.ts` | `<chat_history>`, `<instruction>` — iterative refinement loop, uses action field (create/update/delete) in output files, signals "questions" after each change to stay alive |
Examples within mode-specific tags use `<examples>` > `<example label="good">` / `<example label="bad">` nesting.

View File

@@ -5,8 +5,8 @@
## Architecture
- **Schema**: `apps/server/db/schema.ts` — all tables, columns, relations
- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 10 repository interfaces
- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 10 Drizzle adapters
- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 12 repository interfaces
- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 12 Drizzle adapters
- **Barrel exports**: `apps/server/db/index.ts` re-exports everything
All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.returning()` for atomic reads after writes.
@@ -164,9 +164,40 @@ Inter-agent communication records.
Indexes: `(toAgentId, status)` for listen polling, `(fromAgentId)`.
### chat_sessions
Persistent chat sessions for iterative refinement of phases/tasks.
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | nanoid |
| targetType | text enum | 'phase' \| 'task' |
| targetId | text NOT NULL | phase or task ID |
| initiativeId | text FK → initiatives (cascade) | |
| agentId | text FK → agents (set null) | linked agent |
| status | text enum | 'active' \| 'closed', default 'active' |
| createdAt, updatedAt | integer/timestamp | |
Indexes: `(targetType, targetId)`, `(initiativeId)`.
### chat_messages
Messages within a chat session.
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | nanoid |
| chatSessionId | text FK → chat_sessions (cascade) | |
| role | text enum | 'user' \| 'assistant' \| 'system' |
| content | text NOT NULL | |
| changeSetId | text FK → change_sets (set null) | links assistant messages to applied changes |
| createdAt | integer/timestamp | |
Index: `(chatSessionId)`.
## Repository Interfaces
11 repositories, each with standard CRUD plus domain-specific methods:
12 repositories, each with standard CRUD plus domain-specific methods:
| Repository | Key Methods |
|-----------|-------------|
@@ -181,6 +212,7 @@ Indexes: `(toAgentId, status)` for listen polling, `(fromAgentId)`.
| ProposalRepository | + findByAgentIdAndStatus, updateManyByAgentId, countByAgentIdAndStatus |
| LogChunkRepository | insertChunk, findByAgentId, deleteByAgentId, getSessionCount |
| ConversationRepository | create, findById, findPendingForAgent, answer |
| ChatSessionRepository | createSession, findActiveSession, findActiveSessionByAgentId, updateSession, createMessage, findMessagesBySessionId |
## Migrations
@@ -192,4 +224,4 @@ Key rules:
- See [database-migrations.md](database-migrations.md) for full workflow
- Snapshots stale after 0008; migrations 0008+ are hand-written
Current migrations: 0000 through 0026 (27 total).
Current migrations: 0000 through 0027 (28 total).

View File

@@ -11,7 +11,7 @@
- **Adapter**: `TypedEventBus` using Node.js `EventEmitter`
- All events implement `BaseEvent { type, timestamp, payload }`
### Event Types (52)
### Event Types (54)
| Category | Events | Count |
|----------|--------|-------|
@@ -26,6 +26,7 @@
| **Account** | `account:credentials_refreshed`, `account:credentials_expired`, `account:credentials_validated` | 3 |
| **Preview** | `preview:building`, `preview:ready`, `preview:stopped`, `preview:failed` | 4 |
| **Conversation** | `conversation:created`, `conversation:answered` | 2 | `conversation:created` triggers auto-resume of idle target agents via `resumeForConversation()` |
| **Chat** | `chat:message_created`, `chat:session_closed` | 2 | Chat session lifecycle events |
| **Log** | `log:entry` | 1 |
### Key Event Payloads
@@ -34,7 +35,7 @@
AgentSpawnedEvent { agentId, name, taskId, worktreeId, provider }
AgentStoppedEvent { agentId, name, taskId, reason }
// reason: 'user_requested'|'task_complete'|'error'|'waiting_for_input'|
// 'context_complete'|'plan_complete'|'detail_complete'|'refine_complete'
// 'context_complete'|'plan_complete'|'detail_complete'|'refine_complete'|'chat_complete'
AgentWaitingEvent { agentId, name, taskId, sessionId, questions[] }
AgentOutputEvent { agentId, stream, data }
TaskCompletedEvent { taskId, agentId, success, message }

View File

@@ -126,6 +126,7 @@ shadcn/ui components: badge (6 status variants + xs size), button, card, dialog,
| `useRefineAgent` | Manages refine agent lifecycle for initiative |
| `useDetailAgent` | Manages detail agent for phase planning |
| `useAgentOutput` | Subscribes to live agent output stream |
| `useChatSession` | Manages chat session for phase/task refinement |
| `useConnectionStatus` | Tracks online/offline/reconnecting state |
| `useGlobalKeyboard` | Global keyboard shortcuts (1-4 nav, Cmd+K) |
@@ -168,6 +169,17 @@ Configured in `src/lib/trpc.ts`. Uses `@trpc/react-query` with TanStack Query fo
3. Accept proposals → tasks created under phase
4. View tasks in phase detail panel
### Chat with Phase/Task
1. Execution tab → select phase → "Chat" button (or open task → "Chat" button in footer)
2. `ChatSlideOver` opens as right-side panel
3. Send message → `sendChatMessage` mutation → agent spawns (or resumes) in `'chat'` mode
4. Agent applies changes (create/update/delete phases, tasks, pages) → changeset created
5. Assistant message appears with inline changeset summary + revert button
6. Send next message → agent resumes → repeat
7. Close chat → session closed, agent dismissed
Components: `ChatSlideOver`, `ChatBubble`, `ChatInput`, `ChangeSetInline` in `src/components/chat/`.
## Shared Package
`packages/shared/` exports:

View File

@@ -232,3 +232,17 @@ Inter-agent communication for parallel agents.
Target resolution: `toAgentId` → direct; `taskId` → find running agent by task; `phaseId` → find running agent by any task in phase.
Context dependency: `requireConversationRepository(ctx)`, `requireAgentManager(ctx)`.
## Chat Session Procedures
Persistent chat loop for iterative phase/task refinement via agent.
| Procedure | Type | Description |
|-----------|------|-------------|
| `sendChatMessage` | mutation | Send message: `{targetType, targetId, initiativeId, message, provider?}``{sessionId, agentId, action}` |
| `getChatSession` | query | Get active session with messages: `{targetType, targetId}` → ChatSession \| null |
| `closeChatSession` | mutation | Close session and dismiss agent: `{sessionId}``{success}` |
`sendChatMessage` finds or creates an active session, stores the user message, then either resumes the existing agent (if `waiting_for_input`) or spawns a fresh one with full chat history + initiative context. Agent runs in `'chat'` mode and signals `"questions"` after applying changes, staying alive for the next message.
Context dependency: `requireChatSessionRepository(ctx)`, `requireAgentManager(ctx)`, `requireInitiativeRepository(ctx)`, `requireTaskRepository(ctx)`.