From fcf822363c67e725ab7768e21fc64760279a73c7 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Wed, 4 Mar 2026 10:14:28 +0100 Subject: [PATCH] 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. --- apps/server/agent/file-io.ts | 13 ++ apps/server/agent/manager.ts | 4 +- apps/server/agent/output-handler.ts | 195 ++++++++++++++++++ apps/server/agent/prompts/chat.ts | 71 +++++++ apps/server/agent/prompts/index.ts | 2 + apps/server/agent/types.ts | 2 +- apps/server/container.ts | 10 +- .../db/repositories/change-set-repository.ts | 2 +- .../repositories/chat-session-repository.ts | 31 +++ .../db/repositories/drizzle/chat-session.ts | 107 ++++++++++ apps/server/db/repositories/drizzle/index.ts | 1 + apps/server/db/repositories/drizzle/page.ts | 2 +- apps/server/db/repositories/drizzle/task.ts | 2 +- apps/server/db/repositories/index.ts | 6 + .../server/db/repositories/page-repository.ts | 2 +- .../server/db/repositories/task-repository.ts | 2 +- apps/server/db/schema.ts | 72 ++++++- .../server/drizzle/0027_add_chat_sessions.sql | 26 +++ apps/server/drizzle/meta/_journal.json | 7 + apps/server/events/types.ts | 27 ++- apps/server/trpc/context.ts | 5 + apps/server/trpc/router.ts | 2 + apps/server/trpc/routers/_helpers.ts | 11 + apps/server/trpc/routers/architect.ts | 2 +- apps/server/trpc/routers/change-set.ts | 27 +++ apps/server/trpc/routers/chat-session.ts | 187 +++++++++++++++++ apps/server/trpc/subscriptions.ts | 2 + apps/web/src/components/ExecutionTab.tsx | 17 +- .../src/components/chat/ChangeSetInline.tsx | 112 ++++++++++ apps/web/src/components/chat/ChatBubble.tsx | 36 ++++ apps/web/src/components/chat/ChatInput.tsx | 62 ++++++ .../web/src/components/chat/ChatSlideOver.tsx | 144 +++++++++++++ .../components/execution/PhaseDetailPanel.tsx | 16 +- .../components/execution/TaskSlideOver.tsx | 21 +- apps/web/src/hooks/useChatSession.ts | 137 ++++++++++++ docs/agent.md | 5 +- docs/database.md | 40 +++- docs/dispatch-events.md | 5 +- docs/frontend.md | 12 ++ docs/server-api.md | 14 ++ 40 files changed, 1414 insertions(+), 27 deletions(-) create mode 100644 apps/server/agent/prompts/chat.ts create mode 100644 apps/server/db/repositories/chat-session-repository.ts create mode 100644 apps/server/db/repositories/drizzle/chat-session.ts create mode 100644 apps/server/drizzle/0027_add_chat_sessions.sql create mode 100644 apps/server/trpc/routers/chat-session.ts create mode 100644 apps/web/src/components/chat/ChangeSetInline.tsx create mode 100644 apps/web/src/components/chat/ChatBubble.tsx create mode 100644 apps/web/src/components/chat/ChatInput.tsx create mode 100644 apps/web/src/components/chat/ChatSlideOver.tsx create mode 100644 apps/web/src/hooks/useChatSession.ts diff --git a/apps/server/agent/file-io.ts b/apps/server/agent/file-io.ts index 4bdfab4..f51aa41 100644 --- a/apps/server/agent/file-io.ts +++ b/apps/server/agent/file-io.ts @@ -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, }; }); } diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index ac2bf70..a837848 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -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, diff --git a/apps/server/agent/output-handler.ts b/apps/server/agent/output-handler.ts index 057e20b..2413c17 100644 --- a/apps/server/agent/output-handler.ts +++ b/apps/server/agent/output-handler.ts @@ -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(); + 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'; } } diff --git a/apps/server/agent/prompts/chat.ts b/apps/server/agent/prompts/chat.ts new file mode 100644 index 0000000..9e2af87 --- /dev/null +++ b/apps/server/agent/prompts/chat.ts @@ -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 + ? `\n${chatHistory.map(m => `[${m.role}]: ${m.content}`).join('\n\n')}\n` + : ''; + + return ` +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. + +${INPUT_FILES} +${ID_GENERATION} +${SIGNAL_FORMAT} + +${historyBlock} + + +${userInstruction} + + + +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 + + + +After writing output files, write \`.cw/output/SUMMARY.md\` with a brief description of what you changed and why. + + + +- 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\`) +`; +} diff --git a/apps/server/agent/prompts/index.ts b/apps/server/agent/prompts/index.ts index aecc0b0..8085811 100644 --- a/apps/server/agent/prompts/index.ts +++ b/apps/server/agent/prompts/index.ts @@ -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'; diff --git a/apps/server/agent/types.ts b/apps/server/agent/types.ts index 1a7729c..94737d9 100644 --- a/apps/server/agent/types.ts +++ b/apps/server/agent/types.ts @@ -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. diff --git a/apps/server/container.ts b/apps/server/container.ts index 62726af..d1ed485 100644 --- a/apps/server/container.ts +++ b/apps/server/container.ts @@ -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; + findSessionById(id: string): Promise; + findActiveSession(targetType: 'phase' | 'task', targetId: string): Promise; + findActiveSessionByAgentId(agentId: string): Promise; + updateSession(id: string, data: { agentId?: string | null; status?: 'active' | 'closed' }): Promise; + createMessage(data: CreateChatMessageData): Promise; + findMessagesBySessionId(sessionId: string): Promise; +} diff --git a/apps/server/db/repositories/drizzle/chat-session.ts b/apps/server/db/repositories/drizzle/chat-session.ts new file mode 100644 index 0000000..09ad014 --- /dev/null +++ b/apps/server/db/repositories/drizzle/chat-session.ts @@ -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 { + 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; + } + + async findSessionById(id: string): Promise { + 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 { + 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 { + 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 { + const updates: Record = { 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; + } + + async createMessage(data: CreateChatMessageData): Promise { + 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 { + return this.db + .select() + .from(chatMessages) + .where(eq(chatMessages.chatSessionId, sessionId)) + .orderBy(asc(chatMessages.createdAt)); + } +} diff --git a/apps/server/db/repositories/drizzle/index.ts b/apps/server/db/repositories/drizzle/index.ts index e58eb86..464f884 100644 --- a/apps/server/db/repositories/drizzle/index.ts +++ b/apps/server/db/repositories/drizzle/index.ts @@ -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'; diff --git a/apps/server/db/repositories/drizzle/page.ts b/apps/server/db/repositories/drizzle/page.ts index 268919e..52f502f 100644 --- a/apps/server/db/repositories/drizzle/page.ts +++ b/apps/server/db/repositories/drizzle/page.ts @@ -18,7 +18,7 @@ export class DrizzlePageRepository implements PageRepository { constructor(private db: DrizzleDatabase) {} async create(data: CreatePageData): Promise { - const id = nanoid(); + const id = data.id ?? nanoid(); const now = new Date(); const [created] = await this.db.insert(pages).values({ diff --git a/apps/server/db/repositories/drizzle/task.ts b/apps/server/db/repositories/drizzle/task.ts index d17286c..ea04f69 100644 --- a/apps/server/db/repositories/drizzle/task.ts +++ b/apps/server/db/repositories/drizzle/task.ts @@ -25,7 +25,7 @@ export class DrizzleTaskRepository implements TaskRepository { constructor(private db: DrizzleDatabase) {} async create(data: CreateTaskData): Promise { - const id = nanoid(); + const id = data.id ?? nanoid(); const now = new Date(); const [created] = await this.db.insert(tasks).values({ diff --git a/apps/server/db/repositories/index.ts b/apps/server/db/repositories/index.ts index 1dddc9f..d5b896b 100644 --- a/apps/server/db/repositories/index.ts +++ b/apps/server/db/repositories/index.ts @@ -72,3 +72,9 @@ export type { ConversationRepository, CreateConversationData, } from './conversation-repository.js'; + +export type { + ChatSessionRepository, + CreateChatSessionData, + CreateChatMessageData, +} from './chat-session-repository.js'; diff --git a/apps/server/db/repositories/page-repository.ts b/apps/server/db/repositories/page-repository.ts index 06ceb7d..6eaf308 100644 --- a/apps/server/db/repositories/page-repository.ts +++ b/apps/server/db/repositories/page-repository.ts @@ -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; +export type CreatePageData = Omit & { id?: string }; /** * Data for updating a page. diff --git a/apps/server/db/repositories/task-repository.ts b/apps/server/db/repositories/task-repository.ts index 8ea97fa..a621a1e 100644 --- a/apps/server/db/repositories/task-repository.ts +++ b/apps/server/db/repositories/task-repository.ts @@ -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; +export type CreateTaskData = Omit & { id?: string }; /** * Data for updating a task. diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index f4cbfa4..b27d28f 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -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; export type NewConversation = InferInsertModel; + +// ============================================================================ +// 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; +export type NewChatSession = InferInsertModel; + +// ============================================================================ +// 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; +export type NewChatMessage = InferInsertModel; diff --git a/apps/server/drizzle/0027_add_chat_sessions.sql b/apps/server/drizzle/0027_add_chat_sessions.sql new file mode 100644 index 0000000..348306d --- /dev/null +++ b/apps/server/drizzle/0027_add_chat_sessions.sql @@ -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`); diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index fea8954..fe80b39 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/apps/server/events/types.ts b/apps/server/events/types.ts index a0d317f..6ff9060 100644 --- a/apps/server/events/types.ts +++ b/apps/server/events/types.ts @@ -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 diff --git a/apps/server/trpc/context.ts b/apps/server/trpc/context.ts index 148a844..1b3b89b 100644 --- a/apps/server/trpc/context.ts +++ b/apps/server/trpc/context.ts @@ -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, }; } diff --git a/apps/server/trpc/router.ts b/apps/server/trpc/router.ts index e83aaeb..d1c43fc 100644 --- a/apps/server/trpc/router.ts +++ b/apps/server/trpc/router.ts @@ -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; diff --git a/apps/server/trpc/routers/_helpers.ts b/apps/server/trpc/routers/_helpers.ts index 5fed4a3..cbd4f59 100644 --- a/apps/server/trpc/routers/_helpers.ts +++ b/apps/server/trpc/routers/_helpers.ts @@ -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; +} diff --git a/apps/server/trpc/routers/architect.ts b/apps/server/trpc/routers/architect.ts index 35d850d..0ce23a5 100644 --- a/apps/server/trpc/routers/architect.ts +++ b/apps/server/trpc/routers/architect.ts @@ -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, diff --git a/apps/server/trpc/routers/change-set.ts b/apps/server/trpc/routers/change-set.ts index 4e92b87..344c7f3 100644 --- a/apps/server/trpc/routers/change-set.ts +++ b/apps/server/trpc/routers/change-set.ts @@ -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 diff --git a/apps/server/trpc/routers/chat-session.ts b/apps/server/trpc/routers/chat-session.ts new file mode 100644 index 0000000..2d196e3 --- /dev/null +++ b/apps/server/trpc/routers/chat-session.ts @@ -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 }; + }), + }; +} diff --git a/apps/server/trpc/subscriptions.ts b/apps/server/trpc/subscriptions.ts index 4bf55a3..b056800 100644 --- a/apps/server/trpc/subscriptions.ts +++ b/apps/server/trpc/subscriptions.ts @@ -63,6 +63,8 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [ 'changeset:reverted', 'conversation:created', 'conversation:answered', + 'chat:message_created', + 'chat:session_closed', ]; /** diff --git a/apps/web/src/components/ExecutionTab.tsx b/apps/web/src/components/ExecutionTab.tsx index 7edec19..4401088 100644 --- a/apps/web/src/components/ExecutionTab.tsx +++ b/apps/web/src/components/ExecutionTab.tsx @@ -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(null); const [isAddingPhase, setIsAddingPhase] = useState(false); + const [chatTarget, setChatTarget] = useState(null); const deletePhase = trpc.deletePhase.useMutation({ onSuccess: () => { @@ -180,7 +182,12 @@ export function ExecutionTab({ phases={sortedPhases} onAddPhase={handleStartAdd} /> - + setChatTarget(target)} /> + setChatTarget(null)} + /> ); } @@ -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)} /> ) : ( )} - + setChatTarget(target)} /> + setChatTarget(null)} + /> ); } diff --git a/apps/web/src/components/chat/ChangeSetInline.tsx b/apps/web/src/components/chat/ChangeSetInline.tsx new file mode 100644 index 0000000..a2f57da --- /dev/null +++ b/apps/web/src/components/chat/ChangeSetInline.tsx @@ -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(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 ( +
+ Loading changes... +
+ ); + } + + if (entries.length === 0) return null; + + return ( +
+
+ {entries.map((entry) => ( +
+ + {entry.action === 'create' ? '+' : entry.action === 'delete' ? '-' : '~'} + + + {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 ''; + } + })()} + +
+ ))} +
+ + {isReverted ? ( + Reverted + ) : ( + + )} + + {conflicts && ( +
+

Conflicts:

+
    + {conflicts.map((c, i) => ( +
  • {c}
  • + ))} +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/components/chat/ChatBubble.tsx b/apps/web/src/components/chat/ChatBubble.tsx new file mode 100644 index 0000000..424264d --- /dev/null +++ b/apps/web/src/components/chat/ChatBubble.tsx @@ -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 ( +
+ {message.content} +
+ ); + } + + return ( +
+
+

{message.content}

+ {message.changeSetId && } +
+
+ ); +} diff --git a/apps/web/src/components/chat/ChatInput.tsx b/apps/web/src/components/chat/ChatInput.tsx new file mode 100644 index 0000000..17b9211 --- /dev/null +++ b/apps/web/src/components/chat/ChatInput.tsx @@ -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(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 ( +
+