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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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