refactor: move git generation to session structured output

- Use active session prompts for commit/PR generation and drop backend git generation routes.
- Improve tool activity UX with Structured Output rendering, description styling tweaks, and detailed-mode expansion fixes.
This commit is contained in:
Bohdan Triapitsyn
2026-02-24 15:37:35 +02:00
parent 9462ac8498
commit 4a8c69e36a
11 changed files with 455 additions and 511 deletions
@@ -29,7 +29,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
const DETAILED_DEFAULT_TOOLS = new Set(['task', 'edit', 'multiedit', 'write', 'bash']);
const DETAILED_DEFAULT_TOOLS = new Set(['task', 'edit', 'multiedit', 'write', 'apply_patch', 'bash', 'todowrite']);
const isDetailedDefaultTool = (toolName: unknown): boolean =>
typeof toolName === 'string' && DETAILED_DEFAULT_TOOLS.has(toolName.toLowerCase());
@@ -437,8 +437,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}
const toolPart = activity.part as unknown as { id?: string; tool?: unknown };
if (toolPart.id && isDetailedDefaultTool(toolPart.tool)) {
defaultExpandedToolIds.add(toolPart.id);
if (isDetailedDefaultTool(toolPart.tool)) {
if (toolPart.id) {
defaultExpandedToolIds.add(toolPart.id);
}
if (activity.id) {
defaultExpandedToolIds.add(activity.id);
}
}
}
}
@@ -119,7 +119,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
{summary && (
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70">
<span className="truncate block">{summary}</span>
<span className="truncate block italic">{summary}</span>
</div>
)}
</div>
@@ -1,7 +1,7 @@
import React from 'react';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck2, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
@@ -88,6 +88,9 @@ export const getToolIcon = (toolName: string) => {
if (tool === 'todowrite' || tool === 'todoread') {
return <RiListCheck3 className={iconClass} />;
}
if (tool === 'structuredoutput' || tool === 'structured_output') {
return <RiListCheck2 className={iconClass} />;
}
if (tool === 'skill') {
return <RiBookLine className={iconClass} />;
}
@@ -189,6 +192,11 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile:
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const tool = part.tool.toLowerCase();
if (tool === 'structuredoutput' || tool === 'structured_output') {
return 'Result';
}
if (part.tool === 'apply_patch') {
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
@@ -1391,7 +1399,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta" style={{ color: 'var(--tools-description)' }}>
{justificationText && (
<span className={cn("truncate italic", isMobile && "max-w-[120px]")} style={{ color: 'var(--tools-description)', opacity: 0.8 }}>
<span className={cn("truncate", isMobile && "max-w-[120px]")} style={{ color: 'var(--tools-description)', opacity: 0.8 }}>
{justificationText}
</span>
)}
+12 -18
View File
@@ -62,6 +62,7 @@ import type { GitRemote } from '@/lib/gitApi';
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { cn } from '@/lib/utils';
import { generateCommitMessage as generateSessionCommitMessage } from '@/lib/gitApi';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
type CommitAction = 'commit' | 'commitAndPush' | null;
@@ -820,25 +821,14 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
return;
}
console.error('[git-generation][browser] generate button clicked', {
directory: currentDirectory,
selectedFiles: selectedPaths.size,
});
setIsGeneratingMessage(true);
try {
const { getResolvedGitGenerationModel, settingsZenModel } = useConfigStore.getState();
const resolvedModel = getResolvedGitGenerationModel();
const options: { zenModel?: string; providerId?: string; modelId?: string } = {};
if (resolvedModel) {
options.providerId = resolvedModel.providerId;
options.modelId = resolvedModel.modelId;
if (resolvedModel.providerId === 'zen') {
options.zenModel = resolvedModel.modelId;
}
} else if (settingsZenModel) {
options.zenModel = settingsZenModel;
}
const { message } = await git.generateCommitMessage(
currentDirectory,
Array.from(selectedPaths),
Object.keys(options).length > 0 ? options : undefined
);
const { message } = await generateSessionCommitMessage(currentDirectory, Array.from(selectedPaths));
const subject = message.subject?.trim() ?? '';
const highlights = Array.isArray(message.highlights) ? message.highlights : [];
@@ -859,13 +849,17 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
scrollActionPanelToBottom();
} catch (error) {
console.error('[git-generation][browser] GitView generate handler failed', {
message: error instanceof Error ? error.message : String(error),
error,
});
const message =
error instanceof Error ? error.message : 'Failed to generate commit message';
toast.error(message);
} finally {
setIsGeneratingMessage(false);
}
}, [currentDirectory, selectedPaths, git, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom]);
}, [currentDirectory, selectedPaths, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom]);
const handleCreateBranch = async (branchName: string, remote?: GitRemote) => {
if (!currentDirectory || !status) return;
@@ -1128,24 +1128,13 @@ export const PullRequestSection: React.FC<{
if (!directory) return;
setIsGenerating(true);
try {
const { getResolvedGitGenerationModel, settingsZenModel } = useConfigStore.getState();
const resolvedModel = getResolvedGitGenerationModel();
const payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } = {
const payload: { base: string; head: string; context?: string; files?: string[] } = {
base: targetBaseBranch,
head: branch,
};
if (additionalContext) {
payload.context = additionalContext;
}
if (resolvedModel) {
payload.providerId = resolvedModel.providerId;
payload.modelId = resolvedModel.modelId;
if (resolvedModel.providerId === 'zen') {
payload.zenModel = resolvedModel.modelId;
}
} else if (settingsZenModel) {
payload.zenModel = settingsZenModel;
}
const generated = await generatePullRequestDescription(directory, payload);
if (generated.title?.trim()) {
@@ -1161,7 +1150,7 @@ export const PullRequestSection: React.FC<{
} finally {
setIsGenerating(false);
}
}, [branch, directory, isGenerating, additionalContext, onGeneratedDescription, targetBaseBranch]);
}, [additionalContext, branch, directory, isGenerating, onGeneratedDescription, targetBaseBranch]);
const createPr = React.useCallback(async () => {
if (!github?.prCreate) {
+323 -7
View File
@@ -2,6 +2,10 @@
import type { RuntimeAPIs } from './api/types';
import * as gitHttp from './gitApiHttp';
import { opencodeClient } from './opencode/client';
import { useSessionStore } from '@/stores/useSessionStore';
import { useContextStore } from '@/stores/contextStore';
import { useConfigStore } from '@/stores/useConfigStore';
export type {
GitStatus,
@@ -107,22 +111,334 @@ export async function generateCommitMessage(
files: string[],
options?: { zenModel?: string; providerId?: string; modelId?: string }
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.generateCommitMessage(directory, files, options);
return gitHttp.generateCommitMessage(directory, files, options);
const startedAt = Date.now();
void options;
const generationSession = resolveSessionGenerationContext();
if (!generationSession) {
throw new Error('Select an active session for generation');
}
console.info('[git-generation][browser] request', {
transport: 'session',
kind: 'commit',
directory,
selectedFiles: files.length,
sessionId: generationSession.sessionId,
providerId: generationSession.providerID,
modelId: generationSession.modelID,
agent: generationSession.agent,
variant: generationSession.variant,
});
const prompt = `You are generating a Conventional Commits subject line using session context and selected file paths.
Return JSON with exactly this shape:
{"subject": string, "highlights": string[]}
Rules:
- subject format: <type>: <summary>
- allowed types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert
- no scope in subject
- keep subject concise and user-facing
- highlights: 0-3 concise user-facing points
Selected files:
${files.map((file) => `- ${file}`).join('\n')}`;
try {
const structured = await runStructuredGenerationInActiveSession({
directory,
prompt,
generationSession,
schema: {
type: 'object',
additionalProperties: false,
properties: {
subject: { type: 'string', description: 'Conventional commit subject line.' },
highlights: {
type: 'array',
items: { type: 'string', description: 'Short user-facing highlight.' },
maxItems: 3,
description: 'Optional short user-facing highlights.',
},
},
required: ['subject', 'highlights'],
},
kind: 'commit',
});
const subject = typeof structured.subject === 'string' ? structured.subject.trim() : '';
const highlights = Array.isArray(structured.highlights)
? structured.highlights.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean).slice(0, 3)
: [];
if (!subject) {
throw new Error('Structured output missing subject');
}
const result = { message: { subject, highlights } };
console.info('[git-generation][browser] success', {
transport: 'session',
kind: 'commit',
elapsedMs: Date.now() - startedAt,
subjectLength: result.message.subject.length,
highlightsCount: result.message.highlights.length,
});
return result;
} catch (error) {
console.error('[git-generation][browser] failed', {
transport: 'session',
kind: 'commit',
elapsedMs: Date.now() - startedAt,
message: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
}
export async function generatePullRequestDescription(
directory: string,
payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
): Promise<import('./api/types').GeneratedPullRequestDescription> {
const runtime = getRuntimeGit();
if (runtime?.generatePullRequestDescription) {
return runtime.generatePullRequestDescription(directory, payload);
const startedAt = Date.now();
const generationSession = resolveSessionGenerationContext();
if (!generationSession) {
throw new Error('Select an active session for generation');
}
const commitLog = await getGitLog(directory, {
from: payload.base,
to: payload.head,
maxCount: 50,
});
const commits = (Array.isArray(commitLog?.all) ? commitLog.all : [])
.filter((entry) => typeof entry?.hash === 'string' && entry.hash.length > 0)
.map((entry) => ({
hash: entry.hash,
subject: typeof entry.message === 'string' ? entry.message.trim() : '',
}));
if (commits.length === 0) {
throw new Error(`No commits found in range ${payload.base}...${payload.head}`);
}
const filesSet = new Set<string>();
await Promise.all(commits.map(async (commit) => {
try {
const response = await getCommitFiles(directory, commit.hash);
const files = Array.isArray(response?.files) ? response.files : [];
for (const file of files) {
if (typeof file?.path === 'string' && file.path.trim().length > 0) {
filesSet.add(file.path.trim());
}
}
} catch (error) {
console.warn('[git-generation][browser] failed to collect commit files', {
hash: commit.hash,
error: error instanceof Error ? error.message : String(error),
});
}
}));
const changedFiles = Array.from(filesSet).sort().slice(0, 300);
console.info('[git-generation][browser] request', {
transport: 'session',
kind: 'pr',
directory,
sessionId: generationSession.sessionId,
providerId: generationSession.providerID,
modelId: generationSession.modelID,
agent: generationSession.agent,
variant: generationSession.variant,
base: payload.base,
head: payload.head,
commits: commits.length,
changedFiles: changedFiles.length,
});
const prompt = `You are drafting GitHub Pull Request title and body using session context, commit list, and changed files.
Return JSON with exactly this shape:
{"title": string, "body": string}
Rules:
- title: concise, outcome-first, conventional style
- body: markdown with sections: ## Summary, ## Why, ## Testing
- keep output concrete and user-facing
Base branch: ${payload.base}
Head branch: ${payload.head}
Commits in range (base...head):
${commits.map((commit) => `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`).join('\n')}
Files changed across these commits:
${changedFiles.length > 0 ? changedFiles.map((file) => `- ${file}`).join('\n') : '- none detected'}
${payload.context?.trim() ? `\nAdditional context:\n${payload.context.trim()}` : ''}`;
try {
const structured = await runStructuredGenerationInActiveSession({
directory,
prompt,
generationSession,
schema: {
type: 'object',
additionalProperties: false,
properties: {
title: { type: 'string', description: 'Pull request title.' },
body: { type: 'string', description: 'Pull request markdown description.' },
},
required: ['title', 'body'],
},
kind: 'pr',
});
const result = {
title: typeof structured.title === 'string' ? structured.title.trim() : '',
body: typeof structured.body === 'string' ? structured.body.trim() : '',
};
console.info('[git-generation][browser] success', {
transport: 'session',
kind: 'pr',
elapsedMs: Date.now() - startedAt,
titleLength: result.title.length,
bodyLength: result.body.length,
});
return result;
} catch (error) {
console.error('[git-generation][browser] failed', {
transport: 'session',
kind: 'pr',
elapsedMs: Date.now() - startedAt,
message: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
return gitHttp.generatePullRequestDescription(directory, payload);
}
type SessionGenerationContext = {
sessionId: string;
providerID: string;
modelID: string;
agent?: string;
variant?: string;
};
const resolveSessionGenerationContext = (): SessionGenerationContext | null => {
const sessionId = useSessionStore.getState().currentSessionId;
if (!sessionId) {
return null;
}
const context = useContextStore.getState();
const config = useConfigStore.getState();
const agent = context.getSessionAgentSelection(sessionId) || config.currentAgentName || undefined;
const sessionModel = context.getSessionModelSelection(sessionId);
const agentModel = agent ? context.getAgentModelForSession(sessionId, agent) : null;
const selectedModel = agentModel || sessionModel || (config.currentProviderId && config.currentModelId
? { providerId: config.currentProviderId, modelId: config.currentModelId }
: null);
if (!selectedModel?.providerId || !selectedModel?.modelId) {
return null;
}
const variant = agent
? context.getAgentModelVariantForSession(sessionId, agent, selectedModel.providerId, selectedModel.modelId)
: (config.currentVariant || undefined);
return {
sessionId,
providerID: selectedModel.providerId,
modelID: selectedModel.modelId,
agent,
variant,
};
};
const runStructuredGenerationInActiveSession = async ({
directory,
prompt,
generationSession,
schema,
kind,
}: {
directory: string;
prompt: string;
generationSession: SessionGenerationContext;
schema: Record<string, unknown>;
kind: 'commit' | 'pr';
}): Promise<Record<string, unknown>> => {
const requestStartedAt = Date.now();
console.info('[git-generation][browser] runStructuredGenerationInActiveSession start', {
kind,
directory,
sessionId: generationSession.sessionId,
providerID: generationSession.providerID,
modelID: generationSession.modelID,
agent: generationSession.agent,
variant: generationSession.variant,
});
const trimmedDirectory = typeof directory === 'string' ? directory.trim() : '';
const firstNewlineIndex = prompt.indexOf('\n');
const visiblePrompt = (firstNewlineIndex === -1 ? prompt : prompt.slice(0, firstNewlineIndex)).trim();
const hiddenPrompt = (firstNewlineIndex === -1 ? '' : prompt.slice(firstNewlineIndex + 1)).trim();
const promptParts: Array<{ type: 'text'; text: string; synthetic?: boolean }> = [];
if (visiblePrompt) {
promptParts.push({ type: 'text', text: visiblePrompt, synthetic: false });
}
if (hiddenPrompt) {
promptParts.push({ type: 'text', text: hiddenPrompt, synthetic: true });
}
if (promptParts.length === 0) {
promptParts.push({ type: 'text', text: prompt, synthetic: false });
}
const response = await opencodeClient.withDirectory(directory, async () => {
return opencodeClient.getApiClient().session.prompt({
sessionID: generationSession.sessionId,
...(trimmedDirectory.length > 0 ? { directory: trimmedDirectory } : {}),
model: {
providerID: generationSession.providerID,
modelID: generationSession.modelID,
},
...(generationSession.agent ? { agent: generationSession.agent } : {}),
...(generationSession.variant ? { variant: generationSession.variant } : {}),
format: {
type: 'json_schema',
schema,
retryCount: 2,
},
parts: promptParts,
});
});
const responseError = response?.error as { message?: string } | undefined;
if (!response?.data) {
throw new Error(responseError?.message || `Failed to generate ${kind} output`);
}
const info = response.data.info as { finish?: string; structured_output?: unknown; structured?: unknown; error?: unknown };
const structuredOutput = info?.structured_output || info?.structured;
if (!structuredOutput || typeof structuredOutput !== 'object' || Array.isArray(structuredOutput)) {
console.error('[git-generation][browser] invalid structured output', {
kind,
sessionId: generationSession.sessionId,
elapsedMs: Date.now() - requestStartedAt,
finish: info?.finish,
messageInfo: response.data.info,
messageParts: response.data.parts,
});
throw new Error('No structured output returned by session');
}
return structuredOutput as Record<string, unknown>;
};
export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> {
const runtime = getRuntimeGit();
if (runtime?.worktree?.list) {
+9 -1
View File
@@ -235,7 +235,15 @@ export async function generateCommitMessage(
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to generate commit message');
console.error('[git-generation][browser] http error', {
status: response.status,
statusText: response.statusText,
error,
});
const traceSuffix = typeof error?.traceId === 'string' && error.traceId
? ` (traceId: ${error.traceId})`
: '';
throw new Error(`${error.error || 'Failed to generate commit message'}${traceSuffix}`);
}
const data = await response.json();
+62 -17
View File
@@ -620,6 +620,11 @@ class OpencodeService {
}>;
messageId?: string;
agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>;
format?: {
type: 'json_schema';
schema: Record<string, unknown>;
retryCount?: number;
};
}): Promise<string> {
// Generate a temporary client-side ID for optimistic UI
// This ID won't be sent to the server - server will generate its own
@@ -693,27 +698,67 @@ class OpencodeService {
// for model work (SSE will deliver output/status).
// This avoids 504s from proxy timeouts on long-running turns.
const base = this.baseUrl.replace(/\/+$/, '');
const url = new URL(`${base}/session/${encodeURIComponent(params.id)}/prompt_async`);
if (this.currentDirectory) {
url.searchParams.set('directory', this.currentDirectory);
let url: URL;
try {
url = new URL(`${base}/session/${encodeURIComponent(params.id)}/prompt_async`);
if (this.currentDirectory) {
url.searchParams.set('directory', this.currentDirectory);
}
} catch (error) {
console.error('[git-generation][browser] failed to build prompt_async URL', {
baseUrl: this.baseUrl,
normalizedBase: base,
sessionId: params.id,
directory: this.currentDirectory,
message: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({
model: {
providerID: params.providerID,
modelID: params.modelID,
},
if (params.format) {
console.info('[git-generation][browser] send structured message', {
sessionId: params.id,
providerID: params.providerID,
modelID: params.modelID,
agent: params.agent,
variant: params.variant,
parts,
}),
});
directory: this.currentDirectory,
baseUrl: this.baseUrl,
formatType: params.format.type,
});
}
let response: Response;
try {
response = await fetch(url.toString(), {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({
model: {
providerID: params.providerID,
modelID: params.modelID,
},
agent: params.agent,
variant: params.variant,
...(params.format ? { format: params.format } : {}),
parts,
}),
});
} catch (error) {
console.error('[git-generation][browser] prompt_async request failed before response', {
sessionId: params.id,
url: url.toString(),
directory: this.currentDirectory,
hasFormat: Boolean(params.format),
message: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
if (!response.ok) {
let detail = '';
+14
View File
@@ -186,6 +186,20 @@ export const TOOL_METADATA: Record<string, ToolMetadata> = {
category: 'ai',
outputLanguage: 'text',
inputFields: []
},
StructuredOutput: {
displayName: 'Structured Output',
category: 'ai',
outputLanguage: 'json',
inputFields: []
},
structuredoutput: {
displayName: 'Structured Output',
category: 'ai',
outputLanguage: 'json',
inputFields: []
}
};
+14 -2
View File
@@ -445,7 +445,7 @@ interface MessageState {
interface MessageActions {
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell') => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell', format?: { type: 'json_schema'; schema: Record<string, unknown>; retryCount?: number }) => Promise<void>;
abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
@@ -670,7 +670,7 @@ export const useMessageStore = create<MessageStore>()(
});
},
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal') => {
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal', format?: { type: 'json_schema'; schema: Record<string, unknown>; retryCount?: number }) => {
if (!currentSessionId) {
throw new Error("No session selected");
}
@@ -840,6 +840,17 @@ export const useMessageStore = create<MessageStore>()(
files: filePayloads.length > 0 ? filePayloads : undefined,
});
} else {
if (format) {
console.info('[git-generation][browser] dispatch structured sendMessage', {
sessionId,
providerID,
modelID,
agent,
variant,
directory,
formatType: format.type,
});
}
await opencodeClient.sendMessage({
id: sessionId,
providerID,
@@ -847,6 +858,7 @@ export const useMessageStore = create<MessageStore>()(
text: content,
agent,
variant,
...(format ? { format } : {}),
files: filePayloads.length > 0 ? filePayloads : undefined,
additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined,
agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined,
-447
View File
@@ -604,9 +604,6 @@ let validatedZenFallback = null;
let cachedZenModels = null;
let cachedZenModelsTimestamp = 0;
const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let cachedGitModelCatalog = null;
let cachedGitModelCatalogTimestamp = 0;
const GIT_MODEL_CATALOG_CACHE_TTL = 30 * 1000;
/**
* Fetch free models from the zen API with caching. Returns an array of
@@ -663,219 +660,6 @@ const resolveZenModel = async (override) => {
return validatedZenFallback || ZEN_DEFAULT_MODEL;
};
const getGitModelCatalog = async () => {
const now = Date.now();
if (cachedGitModelCatalog && now - cachedGitModelCatalogTimestamp < GIT_MODEL_CATALOG_CACHE_TTL) {
return cachedGitModelCatalog;
}
const response = await fetch(buildOpenCodeUrl('/model', ''), {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) {
throw new Error(`Failed to fetch model catalog: ${response.status}`);
}
const payload = await response.json().catch(() => null);
const modelRefs = new Set();
if (Array.isArray(payload)) {
for (const item of payload) {
if (!item || typeof item !== 'object') {
continue;
}
const providerID = typeof item.providerID === 'string' ? item.providerID.trim() : '';
const modelID = typeof item.modelID === 'string' ? item.modelID.trim() : '';
if (providerID && modelID) {
modelRefs.add(`${providerID}/${modelID}`);
}
}
}
cachedGitModelCatalog = modelRefs;
cachedGitModelCatalogTimestamp = now;
return modelRefs;
};
/**
* Resolve git generation model based on priority:
* 1) request providerId+modelId
* 2) saved settings gitProviderId+gitModelId
* 3) legacy zenModel from request/settings as zen/<model>
* 4) Zen default (validatedZenFallback || ZEN_DEFAULT_MODEL)
*/
const resolveGitModel = async (requestParams) => {
const { providerId, modelId, zenModel } = requestParams || {};
const requestProviderId = typeof providerId === 'string' ? providerId.trim() : '';
const requestModelId = typeof modelId === 'string' ? modelId.trim() : '';
let modelCatalog = null;
try {
modelCatalog = await getGitModelCatalog();
} catch {
modelCatalog = null;
}
const hasModel = (providerID, modelID) => {
if (!modelCatalog) {
return false;
}
return modelCatalog.has(`${providerID}/${modelID}`);
};
if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) {
return { providerID: requestProviderId, modelID: requestModelId };
}
try {
const settings = await readSettingsFromDisk();
const settingsProviderId = typeof settings?.gitProviderId === 'string' ? settings.gitProviderId.trim() : '';
const settingsModelId = typeof settings?.gitModelId === 'string' ? settings.gitModelId.trim() : '';
if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) {
return { providerID: settingsProviderId, modelID: settingsModelId };
}
} catch {
// ignore
}
const fallbackZenModel = typeof zenModel === 'string' && zenModel.trim().length > 0
? zenModel.trim()
: (await resolveZenModel(zenModel));
return { providerID: 'zen', modelID: fallbackZenModel };
};
const GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000;
const GIT_GENERATION_POLL_INTERVAL_MS = 500;
/**
* Generate text using OpenCode session flow:
* - Create short-lived session
* - POST prompt_async with model and text prompt
* - Poll session messages until final assistant response
* - Extract text from parts
* - Best-effort cleanup of temporary session
*/
const generateWithSessionFlow = async ({ prompt, providerID, modelID }) => {
const completionTimeout = createTimeoutSignal(GIT_GENERATION_TIMEOUT_MS);
let sessionId = null;
try {
const createUrl = buildOpenCodeUrl('/session', '');
const createResponse = await fetch(createUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify({
title: 'Git Generation',
}),
signal: completionTimeout.signal,
});
if (!createResponse.ok) {
const errorBody = await createResponse.json().catch(() => ({}));
throw new Error(`Failed to create session: ${createResponse.status} ${JSON.stringify(errorBody)}`);
}
const sessionData = await createResponse.json();
sessionId = sessionData?.id;
if (!sessionId) {
throw new Error('Session created but no ID returned');
}
const promptUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/prompt_async`, '');
const promptResponse = await fetch(promptUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify({
model: { providerID, modelID },
parts: [{ type: 'text', text: prompt }],
}),
signal: completionTimeout.signal,
});
if (!promptResponse.ok) {
const errorBody = await promptResponse.json().catch(() => ({}));
throw new Error(`Failed to send prompt: ${promptResponse.status} ${JSON.stringify(errorBody)}`);
}
const messagesUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
let lastAssistantText = '';
let pollingAttempts = 0;
const maxPollingAttempts = Math.ceil(GIT_GENERATION_TIMEOUT_MS / GIT_GENERATION_POLL_INTERVAL_MS);
while (pollingAttempts < maxPollingAttempts) {
pollingAttempts++;
await new Promise((resolve) => setTimeout(resolve, GIT_GENERATION_POLL_INTERVAL_MS));
const messagesResponse = await fetch(`${messagesUrl}?limit=10`, {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: completionTimeout.signal,
});
if (!messagesResponse.ok) {
console.warn(`Session messages poll failed: ${messagesResponse.status}`);
continue;
}
const messages = await messagesResponse.json().catch(() => null);
if (!Array.isArray(messages)) {
continue;
}
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg?.info?.role === 'assistant' && msg?.info?.finish === 'stop') {
if (Array.isArray(msg.parts)) {
const textParts = msg.parts
.filter((p) => p?.type === 'text' && typeof p?.text === 'string')
.map((p) => p.text)
.filter(Boolean);
if (textParts.length > 0) {
return textParts.join('\n').trim();
}
}
}
}
}
throw new Error('Timeout waiting for generation to complete');
} finally {
completionTimeout.cleanup();
if (sessionId) {
try {
const deleteUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
await fetch(deleteUrl, {
method: 'DELETE',
headers: getOpenCodeAuthHeaders(),
signal: AbortSignal.timeout(5000),
}).catch((err) => {
console.warn('Failed to cleanup temporary session:', err?.message || err);
});
} catch (err) {
console.warn('Failed to cleanup temporary session:', err?.message || err);
}
}
}
};
const summarizeText = async (text, targetLength, zenModel) => {
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
@@ -1215,53 +999,6 @@ const buildTemplateVariables = async (payload, sessionId) => {
};
};
const stripJsonMarkdownWrapper = (value) => {
if (typeof value !== 'string') {
return '';
}
let trimmed = value.trim();
if (!trimmed) {
return '';
}
if (trimmed.startsWith('```')) {
trimmed = trimmed.replace(/^```(?:json)?\s*/i, '');
const closingFenceIndex = trimmed.lastIndexOf('```');
if (closingFenceIndex !== -1) {
trimmed = trimmed.slice(0, closingFenceIndex);
}
trimmed = trimmed.trim();
}
if (trimmed.endsWith('```')) {
trimmed = trimmed.slice(0, -3).trim();
}
return trimmed;
};
const extractJsonObject = (value) => {
if (typeof value !== 'string') {
return null;
}
const source = value.trim();
if (!source) {
return null;
}
let start = source.indexOf('{');
while (start !== -1) {
let end = source.indexOf('}', start + 1);
while (end !== -1) {
const candidate = source.slice(start, end + 1);
try {
JSON.parse(candidate);
return candidate;
} catch {
end = source.indexOf('}', end + 1);
}
}
start = source.indexOf('{', start + 1);
}
return null;
};
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
@@ -9884,190 +9621,6 @@ async function main(options = {}) {
}
});
app.post('/api/git/commit-message', async (req, res) => {
const { collectDiffs } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const files = Array.isArray(req.body?.files) ? req.body.files : [];
if (files.length === 0) {
return res.status(400).json({ error: 'At least one file is required' });
}
const diffs = await collectDiffs(directory, files);
if (diffs.length === 0) {
return res.status(400).json({ error: 'No diffs available for selected files' });
}
const MAX_DIFF_LENGTH = 4000;
const diffSummaries = diffs
.map(({ path, diff }) => {
const trimmed = diff.length > MAX_DIFF_LENGTH ? `${diff.slice(0, MAX_DIFF_LENGTH)}\n...` : diff;
return `FILE: ${path}\n${trimmed}`;
})
.join('\n\n');
const prompt = `You are generating a Conventional Commits subject line from the provided diff.
Return EXACTLY one JSON object (no code fences, no extra keys, no extra text):
{"subject": string, "highlights": string[]}
Non-negotiable:
- Output must be valid JSON (double quotes).
- Only claim what is supported by the diff. If unsure, be more general; do not guess.
subject:
- Format: <type>: <summary> (NO scope; never write type(scope))
- Allowed types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert
- Choose type (prefer fix when ambiguous):
- fix: any bug/regression/wrong behavior (state, selection, navigation, persistence, crash)
- feat: new user-facing capability or new workflow (not just guardrails/defaults)
- refactor/perf/docs/test/build/ci/style/chore/revert: only when clearly the primary change
- Summary style:
- imperative, present tense, outcome-first
- <= 72 characters, no trailing period
- avoid filenames, internal function names, and implementation details
highlights:
- 0-3 items; it is OK to return [].
- Each item: one plain sentence, <= 90 chars, starts with an Uppercase verb.
- Must add information not already in the subject.
- Prefer user-observable behaviors (UI flow, navigation, selection, default view, persistence).
- No markdown bullets, no file paths, no helper names.
Diff summary (may be truncated):
${diffSummaries}`;
const { providerID, modelID } = await resolveGitModel({
providerId: req.body?.providerId,
modelId: req.body?.modelId,
zenModel: req.body?.zenModel,
});
const raw = await generateWithSessionFlow({ prompt, providerID, modelID });
if (!raw) {
return res.status(502).json({ error: 'No commit message returned by generator' });
}
const cleanedJson = stripJsonMarkdownWrapper(raw);
const extractedJson = extractJsonObject(cleanedJson) || extractJsonObject(raw);
const candidates = [cleanedJson, extractedJson, raw].filter((candidate, index, array) => {
return candidate && array.indexOf(candidate) === index;
});
for (const candidate of candidates) {
if (!(candidate.startsWith('{') || candidate.startsWith('['))) {
continue;
}
try {
const parsed = JSON.parse(candidate);
return res.json({ message: parsed });
} catch (parseError) {
console.warn('Commit message generation returned non-JSON body:', parseError);
}
}
res.json({ message: { subject: raw, highlights: [] } });
} catch (error) {
console.error('Failed to generate commit message:', error);
res.status(500).json({ error: error.message || 'Failed to generate commit message' });
}
});
app.post('/api/git/pr-description', async (req, res) => {
const { getRangeDiff, getRangeFiles } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
if (!base || !head) {
return res.status(400).json({ error: 'base and head are required' });
}
const filesToDiff = await getRangeFiles(directory, { base, head });
const diffs = [];
for (const filePath of filesToDiff) {
const diff = await getRangeDiff(directory, { base, head, path: filePath, contextLines: 3 }).catch(() => '');
if (diff && diff.trim().length > 0) {
diffs.push({ path: filePath, diff });
}
}
if (diffs.length === 0) {
return res.status(400).json({ error: 'No diffs available for base...head' });
}
const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
const context = typeof req.body?.context === 'string' ? req.body.context.trim() : '';
let prompt = `You are drafting a GitHub Pull Request title + description for a squash-merge workflow.
Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:
- Title format: conventional, outcome-first, <= 90 chars, no trailing punctuation.
- Use: <type>(<scope>): <summary>. Types: feat, fix, refactor, perf, docs, test, chore.
- Pick the most important user-facing outcome first; include a second major outcome only when needed.
- Body: GitHub-flavored markdown with sections in this exact order: ## Summary, ## Why, ## Testing.
- Summary: 3-6 bullets, concrete product/workflow impact, no vague filler, no internal helper names.
- Why: 1-3 bullets explaining motivation/tradeoff (what problem this solves for users/devs).
- Testing: checkbox list using "- [ ]"; include realistic manual/automated checks inferred from the diff.
- If tests were not run, include "- [ ] Not run locally" as first testing item.
- Keep language crisp and specific; avoid generic boilerplate.
Context:
- base branch: ${base}
- head branch: ${head}`;
if (context) {
prompt += `\n\nAdditional context provided by user:\n${context}`;
}
prompt += `\n\nDiff summary:\n${diffSummaries}`;
const { providerID, modelID } = await resolveGitModel({
providerId: req.body?.providerId,
modelId: req.body?.modelId,
zenModel: req.body?.zenModel,
});
const raw = await generateWithSessionFlow({ prompt, providerID, modelID });
if (!raw) {
return res.status(502).json({ error: 'No PR description returned by generator' });
}
const cleanedJson = stripJsonMarkdownWrapper(raw);
const extractedJson = extractJsonObject(cleanedJson) || extractJsonObject(raw);
const candidates = [cleanedJson, extractedJson, raw].filter((candidate, index, array) => {
return candidate && array.indexOf(candidate) === index;
});
for (const candidate of candidates) {
if (!(candidate.startsWith('{') || candidate.startsWith('['))) {
continue;
}
try {
const parsed = JSON.parse(candidate);
const title = typeof parsed?.title === 'string' ? parsed.title : '';
const body = typeof parsed?.body === 'string' ? parsed.body : '';
return res.json({ title, body });
} catch (parseError) {
console.warn('PR description generation returned non-JSON body:', parseError);
}
}
return res.json({ title: '', body: raw });
} catch (error) {
console.error('Failed to generate PR description:', error);
return res.status(500).json({ error: error.message || 'Failed to generate PR description' });
}
});
app.post('/api/git/pull', async (req, res) => {
const { pull } = await getGitLibraries();
try {