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:
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
71
apps/server/agent/prompts/chat.ts
Normal file
71
apps/server/agent/prompts/chat.ts
Normal 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>`;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
31
apps/server/db/repositories/chat-session-repository.ts
Normal file
31
apps/server/db/repositories/chat-session-repository.ts
Normal 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[]>;
|
||||
}
|
||||
107
apps/server/db/repositories/drizzle/chat-session.ts
Normal file
107
apps/server/db/repositories/drizzle/chat-session.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -72,3 +72,9 @@ export type {
|
||||
ConversationRepository,
|
||||
CreateConversationData,
|
||||
} from './conversation-repository.js';
|
||||
|
||||
export type {
|
||||
ChatSessionRepository,
|
||||
CreateChatSessionData,
|
||||
CreateChatMessageData,
|
||||
} from './chat-session-repository.js';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
26
apps/server/drizzle/0027_add_chat_sessions.sql
Normal file
26
apps/server/drizzle/0027_add_chat_sessions.sql
Normal 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`);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
187
apps/server/trpc/routers/chat-session.ts
Normal file
187
apps/server/trpc/routers/chat-session.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -63,6 +63,8 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
|
||||
'changeset:reverted',
|
||||
'conversation:created',
|
||||
'conversation:answered',
|
||||
'chat:message_created',
|
||||
'chat:session_closed',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
112
apps/web/src/components/chat/ChangeSetInline.tsx
Normal file
112
apps/web/src/components/chat/ChangeSetInline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/components/chat/ChatBubble.tsx
Normal file
36
apps/web/src/components/chat/ChatBubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/components/chat/ChatInput.tsx
Normal file
62
apps/web/src/components/chat/ChatInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
apps/web/src/components/chat/ChatSlideOver.tsx
Normal file
144
apps/web/src/components/chat/ChatSlideOver.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
137
apps/web/src/hooks/useChatSession.ts
Normal file
137
apps/web/src/hooks/useChatSession.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
Reference in New Issue
Block a user