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

@@ -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>;