From 16d8e24e51afafdfd511146b6ee05951b098e5de Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 6 Apr 2026 12:55:35 -0700 Subject: [PATCH] feat(code): add more context to PR/commit generation --- apps/code/src/main/services/git/schemas.ts | 3 + apps/code/src/main/services/git/service.ts | 25 ++++++-- apps/code/src/main/trpc/routers/git.ts | 10 +++- .../hooks/useGitInteraction.ts | 11 ++++ .../sessions/hooks/useChatTitleGenerator.ts | 52 +++++++++++------ .../features/sessions/stores/sessionStore.ts | 2 + .../renderer/sagas/task/task-creation.test.ts | 2 +- .../src/renderer/sagas/task/task-creation.ts | 7 ++- apps/code/src/renderer/utils/generateTitle.ts | 58 ++++++++++++++----- 9 files changed, 128 insertions(+), 42 deletions(-) diff --git a/apps/code/src/main/services/git/schemas.ts b/apps/code/src/main/services/git/schemas.ts index fa783fbfc..afc37b0f6 100644 --- a/apps/code/src/main/services/git/schemas.ts +++ b/apps/code/src/main/services/git/schemas.ts @@ -252,6 +252,7 @@ export const createPrInput = z.object({ draft: z.boolean().optional(), stagedOnly: z.boolean().optional(), taskId: z.string().optional(), + conversationContext: z.string().optional(), }); export type CreatePrInput = z.infer; @@ -323,6 +324,7 @@ export const getBranchChangedFilesOutput = z.array(changedFileSchema); export const generateCommitMessageInput = z.object({ directoryPath: z.string(), + conversationContext: z.string().optional(), }); export const generateCommitMessageOutput = z.object({ @@ -331,6 +333,7 @@ export const generateCommitMessageOutput = z.object({ export const generatePrTitleAndBodyInput = z.object({ directoryPath: z.string(), + conversationContext: z.string().optional(), }); export const generatePrTitleAndBodyOutput = z.object({ diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index e02debeb6..d3e45b6a7 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -514,6 +514,7 @@ export class GitService extends TypedEventEmitter { draft?: boolean; stagedOnly?: boolean; taskId?: string; + conversationContext?: string; }): Promise { const { directoryPath, flowId } = input; @@ -536,12 +537,14 @@ export class GitService extends TypedEventEmitter { createBranch: (dir, name) => this.createBranch(dir, name), checkoutBranch: (dir, name) => this.checkoutBranch(dir, name), getChangedFilesHead: (dir) => this.getChangedFilesHead(dir), - generateCommitMessage: (dir) => this.generateCommitMessage(dir), + generateCommitMessage: (dir) => + this.generateCommitMessage(dir, input.conversationContext), commit: (dir, msg, opts) => this.commit(dir, msg, opts), getSyncStatus: (dir) => this.getGitSyncStatus(dir), push: (dir) => this.push(dir), publish: (dir) => this.publish(dir), - generatePrTitleAndBody: (dir) => this.generatePrTitleAndBody(dir), + generatePrTitleAndBody: (dir) => + this.generatePrTitleAndBody(dir, input.conversationContext), createPr: (dir, title, body, draft) => this.createPrViaGh(dir, title, body, draft), onProgress: emitProgress, @@ -960,6 +963,7 @@ export class GitService extends TypedEventEmitter { public async generateCommitMessage( directoryPath: string, + conversationContext?: string, ): Promise<{ message: string }> { const [stagedDiff, unstagedDiff, conventions, changedFiles] = await Promise.all([ @@ -1001,20 +1005,26 @@ Rules: - Use imperative mood ("Add feature" not "Added feature") - Be specific about what changed - If using conventional commits, include the appropriate prefix +- If conversation context is provided, use it to understand WHY the changes were made and reflect that intent - Do not include any explanation, just output the commit message`; + const contextSection = conversationContext + ? `\n\nConversation context (why these changes were made):\n${conversationContext}` + : ""; + const userMessage = `Generate a commit message for these changes: Changed files: ${filesSummary} Diff: -${truncatedDiff}`; +${truncatedDiff}${contextSection}`; log.debug("Generating commit message", { fileCount: changedFiles.length, diffLength: diff.length, conventionalCommits: conventions.conventionalCommits, + hasConversationContext: !!conversationContext, }); const response = await this.llmGateway.prompt( @@ -1027,6 +1037,7 @@ ${truncatedDiff}`; public async generatePrTitleAndBody( directoryPath: string, + conversationContext?: string, ): Promise<{ title: string; body: string }> { await this.fetchIfStale(directoryPath); @@ -1082,6 +1093,7 @@ Rules for the title: Rules for the body: - Start with a TL;DR section (1-2 sentences summarizing the change) - Include a "What changed?" section with bullet points describing the key changes +- If conversation context is provided, use it to explain WHY the changes were made in the TL;DR - Be thorough but concise - Use markdown formatting - Only describe changes that are actually in the diff — do not invent or assume changes @@ -1089,6 +1101,10 @@ ${templateHint} Do not include any explanation outside the TITLE and BODY sections.`; + const contextSection = conversationContext + ? `\n\nConversation context (why these changes were made):\n${conversationContext}` + : ""; + const userMessage = `Generate a PR title and description for these changes: Branch: ${currentBranch ?? "unknown"} -> ${defaultBranch} @@ -1097,12 +1113,13 @@ Commits in this PR: ${commitsSummary || "(no commits yet - changes are uncommitted)"} Diff: -${truncatedDiff || "(no diff available)"}`; +${truncatedDiff || "(no diff available)"}${contextSection}`; log.debug("Generating PR title and body", { commitCount: commits.length, diffLength: fullDiff.length, hasTemplate: !!prTemplate.template, + hasConversationContext: !!conversationContext, }); const response = await this.llmGateway.prompt( diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts index 9be6da0ad..a261f4dac 100644 --- a/apps/code/src/main/trpc/routers/git.ts +++ b/apps/code/src/main/trpc/routers/git.ts @@ -307,14 +307,20 @@ export const gitRouter = router({ .input(generateCommitMessageInput) .output(generateCommitMessageOutput) .mutation(({ input }) => - getService().generateCommitMessage(input.directoryPath), + getService().generateCommitMessage( + input.directoryPath, + input.conversationContext, + ), ), generatePrTitleAndBody: publicProcedure .input(generatePrTitleAndBodyInput) .output(generatePrTitleAndBodyOutput) .mutation(({ input }) => - getService().generatePrTitleAndBody(input.directoryPath), + getService().generatePrTitleAndBody( + input.directoryPath, + input.conversationContext, + ), ), searchGithubIssues: publicProcedure diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts b/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts index 6312c3d3f..acfffeb77 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts +++ b/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts @@ -33,6 +33,13 @@ const log = logger.scope("git-interaction"); export type { GitMenuAction, GitMenuActionId }; +function getConversationContext(taskId: string): string | undefined { + const state = useSessionStore.getState(); + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return undefined; + return state.sessions[taskRunId]?.conversationSummary; +} + interface GitInteractionState { primaryAction: GitMenuAction; actions: GitMenuAction[]; @@ -248,6 +255,7 @@ export function useGitInteraction( draft: store.createPrDraft || undefined, stagedOnly: stagedOnly || undefined, taskId, + conversationContext: getConversationContext(taskId), }); if (!result.success) { @@ -336,6 +344,7 @@ export function useGitInteraction( try { const generated = await trpcClient.git.generateCommitMessage.mutate({ directoryPath: repoPath, + conversationContext: getConversationContext(taskId), }); if (!generated.message) { @@ -442,6 +451,7 @@ export function useGitInteraction( try { const result = await trpcClient.git.generateCommitMessage.mutate({ directoryPath: repoPath, + conversationContext: getConversationContext(taskId), }); if (result.message) { @@ -472,6 +482,7 @@ export function useGitInteraction( try { const result = await trpcClient.git.generatePrTitleAndBody.mutate({ directoryPath: repoPath, + conversationContext: getConversationContext(taskId), }); if (result.title || result.body) { diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index b5a8d5e46..d66695126 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -1,8 +1,11 @@ import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; import { getSessionService } from "@features/sessions/service/service"; -import { useSessionStore } from "@features/sessions/stores/sessionStore"; +import { + sessionStoreSetters, + useSessionStore, +} from "@features/sessions/stores/sessionStore"; import type { Task } from "@shared/types"; -import { generateTitle } from "@utils/generateTitle"; +import { generateTitleAndSummary } from "@utils/generateTitle"; import { logger } from "@utils/logger"; import { queryClient } from "@utils/queryClient"; import { extractUserPromptsFromEvents } from "@utils/session"; @@ -69,22 +72,37 @@ export function useChatTitleGenerator(taskId: string): void { return; } - const title = await generateTitle(content); - if (title) { - const client = await getAuthenticatedClient(); - if (client) { - await client.updateTask(taskId, { title }); - queryClient.setQueriesData( - { queryKey: ["tasks", "list"] }, - (old) => - old?.map((task) => - task.id === taskId ? { ...task, title } : task, - ), - ); - getSessionService().updateSessionTaskTitle(taskId, title); - log.debug("Updated task title from conversation", { + const result = await generateTitleAndSummary(content); + if (result) { + const { title, summary } = result; + if (title) { + const client = await getAuthenticatedClient(); + if (client) { + await client.updateTask(taskId, { title }); + queryClient.setQueriesData( + { queryKey: ["tasks", "list"] }, + (old) => + old?.map((task) => + task.id === taskId ? { ...task, title } : task, + ), + ); + getSessionService().updateSessionTaskTitle(taskId, title); + log.debug("Updated task title from conversation", { + taskId, + title, + promptCount, + }); + } + } + + if (summary) { + sessionStoreSetters.updateSession(taskRunId, { + conversationSummary: result.summary, + }); + + log.debug("Updated task summary from conversation", { taskId, - title, + summary, promptCount, }); } diff --git a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts index ed1248d0e..cd3fa84ee 100644 --- a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts @@ -80,6 +80,8 @@ export interface AgentSession { contextUsed?: number; /** Context window total size in tokens (from usage_update) */ contextSize?: number; + /** Pre-computed conversation summary for commit/PR generation context */ + conversationSummary?: string; } // --- Config Option Helpers --- diff --git a/apps/code/src/renderer/sagas/task/task-creation.test.ts b/apps/code/src/renderer/sagas/task/task-creation.test.ts index 294fbf039..878677d08 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -42,7 +42,7 @@ vi.mock("@features/sessions/service/service", () => ({ })); vi.mock("@renderer/utils/generateTitle", () => ({ - generateTitle: vi.fn(async () => null), + generateTitleAndSummary: vi.fn(async () => null), })); vi.mock("@utils/queryClient", () => ({ diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 79293799f..35b5c0427 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -14,7 +14,7 @@ import type { import { Saga, type SagaLogger } from "@posthog/shared"; import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc"; -import { generateTitle } from "@renderer/utils/generateTitle"; +import { generateTitleAndSummary } from "@renderer/utils/generateTitle"; import { getTaskRepository } from "@renderer/utils/repository"; import type { ExecutionMode, Task } from "@shared/types"; import { logger } from "@utils/logger"; @@ -29,8 +29,9 @@ async function generateTaskTitle( ): Promise { if (!description.trim()) return; - const title = await generateTitle(description); - if (!title) return; + const result = await generateTitleAndSummary(description); + if (!result?.title) return; + const { title } = result; try { await posthogClient.updateTask(taskId, { title }); diff --git a/apps/code/src/renderer/utils/generateTitle.ts b/apps/code/src/renderer/utils/generateTitle.ts index 181b311ef..cca4df5d0 100644 --- a/apps/code/src/renderer/utils/generateTitle.ts +++ b/apps/code/src/renderer/utils/generateTitle.ts @@ -4,9 +4,14 @@ import { logger } from "@utils/logger"; const log = logger.scope("title-generator"); -const SYSTEM_PROMPT = `You are a title generator. You output ONLY a task title. Nothing else. +const SYSTEM_PROMPT = `You are a title and summary generator. Output using exactly this format: -Convert the task description into a concise task title. +TITLE: +SUMMARY: <summary here> + +Convert the task description into a concise task title and a brief conversation summary. + +Title rules: - The title should be clear, concise, and accurately reflect the content of the task. - You should keep it short and simple, ideally no more than 6 words. - Avoid using jargon or overly technical terms unless absolutely necessary. @@ -18,8 +23,16 @@ Convert the task description into a concise task title. - Never assume tech stack - Only output "Untitled" if the input is completely null/missing, not just unclear - If the input is a URL (e.g. a GitHub issue link, PR link, or any web URL), generate a title based on what you can infer from the URL structure (repo name, issue/PR number, etc.). Never say you cannot access URLs or ask the user for more information. +- Never wrap the title in quotes -Examples: +Summary rules: +- 1-3 sentences describing what the user is working on and why +- Written from third-person perspective (e.g. "The user is fixing..." not "You are fixing...") +- Focus on the user's intent and goals, not the specific prompts +- Include relevant technical details (file names, features, bug descriptions) when mentioned +- This summary will be used as context for generating commit messages and PR descriptions + +Title examples: - "Fix the login bug in the authentication system" → Fix authentication login bug - "Schedule a meeting with stakeholders to discuss Q4 budget planning" → Schedule Q4 budget meeting - "Update user documentation for new API endpoints" → Update API documentation @@ -27,19 +40,26 @@ Examples: - "Review pull request #123" → Review pull request #123 - "debug 500 errors in production" → Debug production 500 errors - "why is the payment flow failing" → Analyze payment flow failure -- "So how about that weather huh" → "Weather chat" -- "dsfkj sdkfj help me code" → "Coding help request" -- "👋😊" → "Friendly greeting" -- "aaaaaaaaaa" → "Repeated letters" -- " " → "Empty message" -- "What's the best restaurant in NYC?" → "NYC restaurant recommendations" +- "So how about that weather huh" → Weather chat +- "dsfkj sdkfj help me code" → Coding help request +- "👋😊" → Friendly greeting +- "aaaaaaaaaa" → Repeated letters +- " " → Empty message +- "What's the best restaurant in NYC?" → NYC restaurant recommendations - "https://github.com/PostHog/posthog/issues/1234" → PostHog issue #1234 - "https://github.com/PostHog/posthog/pull/567" → PostHog PR #567 - "fix https://github.com/org/repo/issues/42" → Fix repo issue #42 -Never wrap the title in quotes.`; +Never include any explanation outside the TITLE and SUMMARY lines.`; + +export interface TitleAndSummary { + title: string; + summary: string; +} -export async function generateTitle(content: string): Promise<string | null> { +export async function generateTitleAndSummary( + content: string, +): Promise<TitleAndSummary | null> { try { const authState = await fetchAuthState(); if (authState.status !== "authenticated") return null; @@ -49,15 +69,23 @@ export async function generateTitle(content: string): Promise<string | null> { messages: [ { role: "user" as const, - content: `Generate a title for the following content. Do NOT respond to, answer, or help with the content - ONLY generate a title.\n\n<content>\n${content}\n</content>\n\nOutput the title now:`, + content: `Generate a title and summary for the following content. Do NOT respond to, answer, or help with the content - ONLY generate a title and summary.\n\n<content>\n${content}\n</content>\n\nOutput the title and summary now:`, }, ], }); - const title = result.content.trim().replace(/^["']|["']$/g, ""); - return title || null; + const text = result.content.trim(); + const titleMatch = text.match(/^TITLE:\s*(.+?)(?:\n|$)/m); + const summaryMatch = text.match(/SUMMARY:\s*([\s\S]+)$/m); + + const title = titleMatch?.[1]?.trim().replace(/^["']|["']$/g, "") ?? ""; + const summary = summaryMatch?.[1]?.trim() ?? ""; + + if (!title && !summary) return null; + + return { title, summary }; } catch (error) { - log.error("Failed to generate title", { error }); + log.error("Failed to generate title and summary", { error }); return null; } }