diff --git a/CHANGELOG.md b/CHANGELOG.md index 03cf8954..0f4a2a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - **Dictation:** speech is now transcribed after you stop talking, instead of being re-guessed word by word while you speak. The offline models OpenChamber runs are built to read a whole utterance at once, so the running transcript was consistently worse than the final one. While recording, the composer shows a live waveform of your voice and a timer, then Transcribing while the text is produced. Long recordings are split at pauses in your speech rather than on a timer, so a three-minute dictation still returns a few seconds after you stop, and words are no longer cut in half at the split. +- Git: generated commit messages now follow the style of the repository's recent commits, including their language, so repositories that commit in Korean or without a `feat:`-style prefix get messages that match instead of English Conventional Commits. +- Git: generating a pull request description now picks up the repository's own PR template when it has one, so the draft comes back in your project's sections and checklists instead of the built-in Summary/Why/Testing layout. - Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). ## [1.19.0] - 2026-08-19 diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 1192cb06..8e3c9d49 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -7,6 +7,7 @@ import { materializeOpenDraftSession, useSessionUIStore } from '@/sync/session-u import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { runtimeFetch } from '@/lib/runtime-fetch'; export type { GitRemote, @@ -247,6 +248,30 @@ const collectSelectedFileDiffs = async (directory: string, files: string[]): Pro return total; }; +const COMMIT_STYLE_SAMPLE_COUNT = 10; +const COMMIT_STYLE_SUBJECT_CHAR_LIMIT = 200; + +// Recent commit subjects give the model the repository's own commit style — +// language, prefixes, capitalization — instead of a hardcoded English default. +// A repository with no history yet is normal, so an empty sample is not an error. +const collectRecentCommitSubjects = async (directory: string): Promise => { + try { + const log = await getGitLog(directory, { maxCount: COMMIT_STYLE_SAMPLE_COUNT }); + const subjects = (Array.isArray(log?.all) ? log.all : []) + .map((entry) => (typeof entry?.message === 'string' ? entry.message.trim() : '')) + .filter(Boolean) + .map((subject) => subject.slice(0, COMMIT_STYLE_SUBJECT_CHAR_LIMIT)); + if (subjects.length === 0) return '(no commits yet)'; + return subjects.map((subject) => `- ${subject}`).join('\n'); + } catch (error) { + console.warn('[git-generation][browser] failed to collect recent commit subjects', { + directory, + error: error instanceof Error ? error.message : String(error), + }); + return '(recent commits unavailable)'; + } +}; + const parseCommitStructured = (structured: Record | null): { subject: string; highlights: string[] } => { const subject = typeof structured?.subject === 'string' ? structured.subject.trim() : ''; const highlights = Array.isArray(structured?.highlights) @@ -293,9 +318,11 @@ export async function generateCommitMessage( selectedFiles: files.length, }); + const recentCommits = await collectRecentCommitSubjects(directory); const visiblePrompt = await renderMagicPrompt('git.commit.generate.visible'); const hiddenPrompt = await renderMagicPrompt('git.commit.generate.instructions', { selected_files: files.map((file) => `- ${file}`).join('\n'), + recent_commits: recentCommits, }); try { @@ -355,6 +382,62 @@ export async function generateCommitMessage( } } +// Conventional pull request template locations. GitHub resolves `.github/` +// first, then the repository root, then `docs/`; both casings are probed +// because case-sensitive filesystems treat them as different files. GitLab +// keeps its merge request templates in `.gitlab/merge_request_templates/`, +// where `Default.md` is the one applied without an explicit choice. +const PULL_REQUEST_TEMPLATE_PATHS = [ + '.github/pull_request_template.md', + '.github/PULL_REQUEST_TEMPLATE.md', + 'pull_request_template.md', + 'PULL_REQUEST_TEMPLATE.md', + 'docs/pull_request_template.md', + 'docs/PULL_REQUEST_TEMPLATE.md', + '.gitlab/merge_request_templates/Default.md', +] as const; + +const PULL_REQUEST_TEMPLATE_CHAR_LIMIT = 8_000; + +const readOptionalRepoTextFile = async (directory: string, relativePath: string): Promise => { + const absolutePath = `${directory.replace(/\/+$/, '')}/${relativePath}`; + const runtimeFiles = getRegisteredRuntimeAPIs()?.files; + if (runtimeFiles?.readFile) { + try { + const result = await runtimeFiles.readFile(absolutePath, { optional: true, directory }); + return result.content ?? null; + } catch { + return null; + } + } + try { + const params = new URLSearchParams({ path: absolutePath, directory, optional: 'true' }); + const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' }); + if (!response.ok) return null; + return await response.text(); + } catch { + return null; + } +}; + +// A repository that ships a PR template expects descriptions in its shape, so +// the template wins over the built-in section layout. Missing template is the +// normal case, not a failure: probing stops at the first file that has content. +const collectPullRequestTemplate = async (directory: string): Promise => { + for (const relativePath of PULL_REQUEST_TEMPLATE_PATHS) { + const content = await readOptionalRepoTextFile(directory, relativePath); + const trimmed = content?.trim(); + if (!trimmed) continue; + console.info('[git-generation][browser] pull request template detected', { + directory, + template: relativePath, + length: trimmed.length, + }); + return `\nRepository pull request template (${relativePath}) — use it as the body structure:\n${trimmed.slice(0, PULL_REQUEST_TEMPLATE_CHAR_LIMIT)}`; + } + return ''; +}; + export async function generatePullRequestDescription( directory: string, payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } @@ -420,6 +503,7 @@ export async function generatePullRequestDescription( }).join('\n'), changed_files: changedFiles.length > 0 ? changedFiles.map((file) => `- ${file}`).join('\n') : '- none detected', additional_context_block: payload.context?.trim() ? `\nAdditional context:\n${payload.context.trim()}` : '', + pr_template_block: await collectPullRequestTemplate(directory), }); const parsePrStructured = (structured: Record | null) => ({ diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts index 07a31208..a2bb430d 100644 --- a/packages/ui/src/lib/magicPrompts.ts +++ b/packages/ui/src/lib/magicPrompts.ts @@ -83,6 +83,7 @@ const MAGIC_PROMPT_DEFINITIONS: readonly MagicPromptDefinition[] = [ description: 'Hidden instructions for commit message generation.', placeholders: [ { key: 'selected_files', description: 'Bullet list of currently selected file paths.' }, + { key: 'recent_commits', description: 'Subjects of the most recent commits on the current branch.' }, ], template: `Return exactly one JSON object and nothing else. Do not include prose, markdown, explanations, or code fences. @@ -90,14 +91,17 @@ The JSON object must have exactly this shape: {"subject": string, "highlights": string[]} Rules: -- subject format: : -- allowed types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert -- no scope in subject +- match the style of the recent commits below: their language, capitalization, use or absence of a type prefix or scope, and typical length +- if the recent commits are written in a language other than English, write the subject and highlights in that language +- when the recent commits show no consistent style, use the format : with one of: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert, and no scope - keep subject concise and user-facing - highlights: 0-3 concise user-facing points - use double quotes for all JSON strings - do not include trailing commas or comments +Recent commits on this branch (newest first): +{{recent_commits}} + Selected files: {{selected_files}}`, }, @@ -119,6 +123,7 @@ Selected files: { key: 'commits', description: 'Bullet list of commits in base...head.' }, { key: 'changed_files', description: 'Bullet list of changed files in base...head.' }, { key: 'additional_context_block', description: 'Optional Additional context block (already formatted).' }, + { key: 'pr_template_block', description: 'Optional repository pull request template block (already formatted, empty when the repo has none).' }, ], template: `Return exactly one JSON object and nothing else. Do not include prose, markdown outside JSON, explanations, or code fences. @@ -127,7 +132,8 @@ The JSON object must have exactly this shape: Rules: - title: concise, outcome-first, conventional style -- body: markdown with sections: ## Summary, ## Why, ## Testing +- body: when a repository pull request template is included below, reuse it as the body — keep its headings, order, wording, comments stripped, and checklists, and fill each section from the commits and changed files; leave a section empty rather than inventing content for it +- body when no template is included: markdown with sections: ## Summary, ## Why, ## Testing - keep output concrete and user-facing - put all markdown inside the body string - use double quotes for all JSON strings and escape newlines as \\n @@ -140,7 +146,7 @@ Commits in range (base...head): {{commits}} Files changed across these commits: -{{changed_files}}{{additional_context_block}}`, +{{changed_files}}{{additional_context_block}}{{pr_template_block}}`, }, { id: 'github.pr.review.visible',