Fix: Restore views properly, model selection for commit and pr and speed up some cli models with session resume (#801)

* Changes from fix/restoring-view

* feat: Add resume query safety checks and optimize store selectors

* feat: Improve session management and model normalization

* refactor: Extract prompt building logic and handle file path parsing for renames
This commit is contained in:
gsxdsm
2026-02-22 10:45:45 -08:00
committed by GitHub
parent 2f071a1ba3
commit 9305ecc242
26 changed files with 761 additions and 203 deletions

View File

@@ -51,6 +51,7 @@ import { CODEX_MODELS } from './codex-models.js';
const CODEX_COMMAND = 'codex'; const CODEX_COMMAND = 'codex';
const CODEX_EXEC_SUBCOMMAND = 'exec'; const CODEX_EXEC_SUBCOMMAND = 'exec';
const CODEX_RESUME_SUBCOMMAND = 'resume';
const CODEX_JSON_FLAG = '--json'; const CODEX_JSON_FLAG = '--json';
const CODEX_MODEL_FLAG = '--model'; const CODEX_MODEL_FLAG = '--model';
const CODEX_VERSION_FLAG = '--version'; const CODEX_VERSION_FLAG = '--version';
@@ -355,9 +356,14 @@ function resolveSystemPrompt(systemPrompt?: unknown): string | null {
return null; return null;
} }
function buildPromptText(options: ExecuteOptions): string {
return typeof options.prompt === 'string'
? options.prompt
: extractTextFromContent(options.prompt);
}
function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string { function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string {
const promptText = const promptText = buildPromptText(options);
typeof options.prompt === 'string' ? options.prompt : extractTextFromContent(options.prompt);
const historyText = options.conversationHistory const historyText = options.conversationHistory
? formatHistoryAsText(options.conversationHistory) ? formatHistoryAsText(options.conversationHistory)
: ''; : '';
@@ -370,6 +376,11 @@ function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string
return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`; return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`;
} }
function buildResumePrompt(options: ExecuteOptions): string {
const promptText = buildPromptText(options);
return `${HISTORY_HEADER}${promptText}`;
}
function formatConfigValue(value: string | number | boolean): string { function formatConfigValue(value: string | number | boolean): string {
return String(value); return String(value);
} }
@@ -793,16 +804,22 @@ export class CodexProvider extends BaseProvider {
} }
const searchEnabled = const searchEnabled =
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools); codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
const schemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat); const isResumeQuery = Boolean(options.sdkSessionId);
const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : []; const schemaPath = isResumeQuery
const imagePaths = await writeImageFiles(options.cwd, imageBlocks); ? null
: await writeOutputSchemaFile(options.cwd, options.outputFormat);
const imageBlocks =
!isResumeQuery && codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
const imagePaths = isResumeQuery ? [] : await writeImageFiles(options.cwd, imageBlocks);
const approvalPolicy = const approvalPolicy =
hasMcpServers && options.mcpAutoApproveTools !== undefined hasMcpServers && options.mcpAutoApproveTools !== undefined
? options.mcpAutoApproveTools ? options.mcpAutoApproveTools
? 'never' ? 'never'
: 'on-request' : 'on-request'
: codexSettings.approvalPolicy; : codexSettings.approvalPolicy;
const promptText = buildCombinedPrompt(options, combinedSystemPrompt); const promptText = isResumeQuery
? buildResumePrompt(options)
: buildCombinedPrompt(options, combinedSystemPrompt);
const commandPath = executionPlan.cliPath || CODEX_COMMAND; const commandPath = executionPlan.cliPath || CODEX_COMMAND;
// Build config overrides for max turns and reasoning effort // Build config overrides for max turns and reasoning effort
@@ -832,21 +849,30 @@ export class CodexProvider extends BaseProvider {
const preExecArgs: string[] = []; const preExecArgs: string[] = [];
// Add additional directories with write access // Add additional directories with write access
if (codexSettings.additionalDirs && codexSettings.additionalDirs.length > 0) { if (
!isResumeQuery &&
codexSettings.additionalDirs &&
codexSettings.additionalDirs.length > 0
) {
for (const dir of codexSettings.additionalDirs) { for (const dir of codexSettings.additionalDirs) {
preExecArgs.push(CODEX_ADD_DIR_FLAG, dir); preExecArgs.push(CODEX_ADD_DIR_FLAG, dir);
} }
} }
// If images were written to disk, add the image directory so the CLI can access them // If images were written to disk, add the image directory so the CLI can access them.
// Note: imagePaths is set to [] when isResumeQuery is true, so this check is sufficient.
if (imagePaths.length > 0) { if (imagePaths.length > 0) {
const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR); const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR);
preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir); preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir);
} }
// Model is already bare (no prefix) - validated by executeQuery // Model is already bare (no prefix) - validated by executeQuery
const codexCommand = isResumeQuery
? [CODEX_EXEC_SUBCOMMAND, CODEX_RESUME_SUBCOMMAND]
: [CODEX_EXEC_SUBCOMMAND];
const args = [ const args = [
CODEX_EXEC_SUBCOMMAND, ...codexCommand,
CODEX_YOLO_FLAG, CODEX_YOLO_FLAG,
CODEX_SKIP_GIT_REPO_CHECK_FLAG, CODEX_SKIP_GIT_REPO_CHECK_FLAG,
...preExecArgs, ...preExecArgs,
@@ -855,6 +881,7 @@ export class CodexProvider extends BaseProvider {
CODEX_JSON_FLAG, CODEX_JSON_FLAG,
...configOverrideArgs, ...configOverrideArgs,
...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []), ...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []),
...(options.sdkSessionId ? [options.sdkSessionId] : []),
'-', // Read prompt from stdin to avoid shell escaping issues '-', // Read prompt from stdin to avoid shell escaping issues
]; ];

View File

@@ -30,6 +30,7 @@ import {
type CopilotRuntimeModel, type CopilotRuntimeModel,
} from '@automaker/types'; } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils'; import { createLogger, isAbortError } from '@automaker/utils';
import { resolveModelString } from '@automaker/model-resolver';
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk'; import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
import { import {
normalizeTodos, normalizeTodos,
@@ -116,6 +117,12 @@ export interface CopilotError extends Error {
suggestion?: string; suggestion?: string;
} }
type CopilotSession = Awaited<ReturnType<CopilotClient['createSession']>>;
type CopilotSessionOptions = Parameters<CopilotClient['createSession']>[0];
type ResumableCopilotClient = CopilotClient & {
resumeSession?: (sessionId: string, options: CopilotSessionOptions) => Promise<CopilotSession>;
};
// ============================================================================= // =============================================================================
// Tool Name Normalization // Tool Name Normalization
// ============================================================================= // =============================================================================
@@ -516,7 +523,11 @@ export class CopilotProvider extends CliProvider {
} }
const promptText = this.extractPromptText(options); const promptText = this.extractPromptText(options);
const bareModel = options.model || DEFAULT_BARE_MODEL; // resolveModelString may return dash-separated canonical names (e.g. "claude-sonnet-4-6"),
// but the Copilot SDK expects dot-separated version suffixes (e.g. "claude-sonnet-4.6").
// Normalize by converting the last dash-separated numeric pair to dot notation.
const resolvedModel = resolveModelString(options.model || DEFAULT_BARE_MODEL);
const bareModel = resolvedModel.replace(/-(\d+)-(\d+)$/, '-$1.$2');
const workingDirectory = options.cwd || process.cwd(); const workingDirectory = options.cwd || process.cwd();
logger.debug( logger.debug(
@@ -554,12 +565,14 @@ export class CopilotProvider extends CliProvider {
}); });
}; };
// Declare session outside try so it's accessible in the catch block for cleanup.
let session: CopilotSession | undefined;
try { try {
await client.start(); await client.start();
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`); logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
// Create session with streaming enabled for real-time events const sessionOptions: CopilotSessionOptions = {
const session = await client.createSession({
model: bareModel, model: bareModel,
streaming: true, streaming: true,
// AUTONOMOUS MODE: Auto-approve all permission requests. // AUTONOMOUS MODE: Auto-approve all permission requests.
@@ -572,13 +585,33 @@ export class CopilotProvider extends CliProvider {
logger.debug(`Permission request: ${request.kind}`); logger.debug(`Permission request: ${request.kind}`);
return { kind: 'approved' }; return { kind: 'approved' };
}, },
}); };
const sessionId = session.sessionId; // Resume the previous Copilot session when possible; otherwise create a fresh one.
logger.debug(`Session created: ${sessionId}`); const resumableClient = client as ResumableCopilotClient;
let sessionResumed = false;
if (options.sdkSessionId && typeof resumableClient.resumeSession === 'function') {
try {
session = await resumableClient.resumeSession(options.sdkSessionId, sessionOptions);
sessionResumed = true;
logger.debug(`Resumed Copilot session: ${session.sessionId}`);
} catch (resumeError) {
logger.warn(
`Failed to resume Copilot session "${options.sdkSessionId}", creating a new session: ${resumeError}`
);
session = await client.createSession(sessionOptions);
}
} else {
session = await client.createSession(sessionOptions);
}
// session is always assigned by this point (both branches above assign it)
const activeSession = session!;
const sessionId = activeSession.sessionId;
logger.debug(`Session ${sessionResumed ? 'resumed' : 'created'}: ${sessionId}`);
// Set up event handler to push events to queue // Set up event handler to push events to queue
session.on((event: SdkEvent) => { activeSession.on((event: SdkEvent) => {
logger.debug(`SDK event: ${event.type}`); logger.debug(`SDK event: ${event.type}`);
if (event.type === 'session.idle') { if (event.type === 'session.idle') {
@@ -596,7 +629,7 @@ export class CopilotProvider extends CliProvider {
}); });
// Send the prompt (non-blocking) // Send the prompt (non-blocking)
await session.send({ prompt: promptText }); await activeSession.send({ prompt: promptText });
// Process events as they arrive // Process events as they arrive
while (!sessionComplete || eventQueue.length > 0) { while (!sessionComplete || eventQueue.length > 0) {
@@ -604,7 +637,7 @@ export class CopilotProvider extends CliProvider {
// Check for errors first (before processing events to avoid race condition) // Check for errors first (before processing events to avoid race condition)
if (sessionError) { if (sessionError) {
await session.destroy(); await activeSession.destroy();
await client.stop(); await client.stop();
throw sessionError; throw sessionError;
} }
@@ -624,11 +657,19 @@ export class CopilotProvider extends CliProvider {
} }
// Cleanup // Cleanup
await session.destroy(); await activeSession.destroy();
await client.stop(); await client.stop();
logger.debug('CopilotClient stopped successfully'); logger.debug('CopilotClient stopped successfully');
} catch (error) { } catch (error) {
// Ensure client is stopped on error // Ensure session is destroyed and client is stopped on error to prevent leaks.
// The session may have been created/resumed before the error occurred.
if (session) {
try {
await session.destroy();
} catch (sessionCleanupError) {
logger.debug(`Failed to destroy session during cleanup: ${sessionCleanupError}`);
}
}
try { try {
await client.stop(); await client.stop();
} catch (cleanupError) { } catch (cleanupError) {

View File

@@ -450,6 +450,11 @@ export class CursorProvider extends CliProvider {
cliArgs.push('--model', model); cliArgs.push('--model', model);
} }
// Resume an existing chat when a provider session ID is available
if (options.sdkSessionId) {
cliArgs.push('--resume', options.sdkSessionId);
}
// Use '-' to indicate reading prompt from stdin // Use '-' to indicate reading prompt from stdin
cliArgs.push('-'); cliArgs.push('-');

View File

@@ -270,6 +270,11 @@ export class GeminiProvider extends CliProvider {
cliArgs.push('--include-directories', options.cwd); cliArgs.push('--include-directories', options.cwd);
} }
// Resume an existing Gemini session when one is available
if (options.sdkSessionId) {
cliArgs.push('--resume', options.sdkSessionId);
}
// Note: Gemini CLI doesn't have a --thinking-level flag. // Note: Gemini CLI doesn't have a --thinking-level flag.
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro). // Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
// The model handles thinking internally based on the task complexity. // The model handles thinking internally based on the task complexity.

View File

@@ -5,12 +5,12 @@
* 1. Discard ALL changes (when no files array is provided) * 1. Discard ALL changes (when no files array is provided)
* - Resets staged changes (git reset HEAD) * - Resets staged changes (git reset HEAD)
* - Discards modified tracked files (git checkout .) * - Discards modified tracked files (git checkout .)
* - Removes untracked files and directories (git clean -fd) * - Removes untracked files and directories (git clean -ffd)
* *
* 2. Discard SELECTED files (when files array is provided) * 2. Discard SELECTED files (when files array is provided)
* - Unstages selected staged files (git reset HEAD -- <files>) * - Unstages selected staged files (git reset HEAD -- <files>)
* - Reverts selected tracked file changes (git checkout -- <files>) * - Reverts selected tracked file changes (git checkout -- <files>)
* - Removes selected untracked files (git clean -fd -- <files>) * - Removes selected untracked files (git clean -ffd -- <files>)
* *
* Note: Git repository validation (isGitRepo) is handled by * Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts * the requireGitRepoOnly middleware in index.ts
@@ -52,6 +52,22 @@ function validateFilePath(filePath: string, worktreePath: string): boolean {
} }
} }
/**
* Parse a file path from git status --porcelain output, handling renames.
* For renamed files (R status), git reports "old_path -> new_path" and
* we need the new path to match what parseGitStatus() returns in git-utils.
*/
function parseFilePath(rawPath: string, indexStatus: string, workTreeStatus: string): string {
const trimmedPath = rawPath.trim();
if (indexStatus === 'R' || workTreeStatus === 'R') {
const arrowIndex = trimmedPath.indexOf(' -> ');
if (arrowIndex !== -1) {
return trimmedPath.slice(arrowIndex + 4);
}
}
return trimmedPath;
}
export function createDiscardChangesHandler() { export function createDiscardChangesHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
@@ -91,11 +107,16 @@ export function createDiscardChangesHandler() {
// Parse the status output to categorize files // Parse the status output to categorize files
// Git --porcelain format: XY PATH where X=index status, Y=worktree status // Git --porcelain format: XY PATH where X=index status, Y=worktree status
// Preserve the exact two-character XY status (no trim) to keep index vs worktree info // For renamed files: XY OLD_PATH -> NEW_PATH
const statusLines = status.trim().split('\n').filter(Boolean); const statusLines = status.trim().split('\n').filter(Boolean);
const allFiles = statusLines.map((line) => { const allFiles = statusLines.map((line) => {
const fileStatus = line.substring(0, 2); const fileStatus = line.substring(0, 2);
const filePath = line.slice(3).trim(); const rawPath = line.slice(3);
const indexStatus = fileStatus.charAt(0);
const workTreeStatus = fileStatus.charAt(1);
// Parse path consistently with parseGitStatus() in git-utils,
// which extracts the new path for renames
const filePath = parseFilePath(rawPath, indexStatus, workTreeStatus);
return { status: fileStatus, path: filePath }; return { status: fileStatus, path: filePath };
}); });
@@ -122,8 +143,12 @@ export function createDiscardChangesHandler() {
const untrackedFiles: string[] = []; // Untracked files (?) const untrackedFiles: string[] = []; // Untracked files (?)
const warnings: string[] = []; const warnings: string[] = [];
// Track which requested files were matched so we can handle unmatched ones
const matchedFiles = new Set<string>();
for (const file of allFiles) { for (const file of allFiles) {
if (!filesToDiscard.has(file.path)) continue; if (!filesToDiscard.has(file.path)) continue;
matchedFiles.add(file.path);
// file.status is the raw two-character XY git porcelain status (no trim) // file.status is the raw two-character XY git porcelain status (no trim)
// X = index/staging status, Y = worktree status // X = index/staging status, Y = worktree status
@@ -151,6 +176,16 @@ export function createDiscardChangesHandler() {
} }
} }
// Handle files from the UI that didn't match any entry in allFiles.
// This can happen due to timing differences between the UI loading diffs
// and the discard request, or path format differences.
// Attempt to clean unmatched files directly as untracked files.
for (const requestedFile of files) {
if (!matchedFiles.has(requestedFile)) {
untrackedFiles.push(requestedFile);
}
}
// 1. Unstage selected staged files (using execFile to bypass shell) // 1. Unstage selected staged files (using execFile to bypass shell)
if (stagedFiles.length > 0) { if (stagedFiles.length > 0) {
try { try {
@@ -174,9 +209,10 @@ export function createDiscardChangesHandler() {
} }
// 3. Remove selected untracked files // 3. Remove selected untracked files
// Use -ffd (double force) to also handle nested git repositories
if (untrackedFiles.length > 0) { if (untrackedFiles.length > 0) {
try { try {
await execGitCommand(['clean', '-fd', '--', ...untrackedFiles], worktreePath); await execGitCommand(['clean', '-ffd', '--', ...untrackedFiles], worktreePath);
} catch (error) { } catch (error) {
const msg = getErrorMessage(error); const msg = getErrorMessage(error);
logError(error, `Failed to clean untracked files: ${msg}`); logError(error, `Failed to clean untracked files: ${msg}`);
@@ -234,11 +270,12 @@ export function createDiscardChangesHandler() {
} }
// 3. Remove untracked files and directories // 3. Remove untracked files and directories
// Use -ffd (double force) to also handle nested git repositories
try { try {
await execGitCommand(['clean', '-fd'], worktreePath); await execGitCommand(['clean', '-ffd', '--'], worktreePath);
} catch (error) { } catch (error) {
const msg = getErrorMessage(error); const msg = getErrorMessage(error);
logError(error, `git clean -fd failed: ${msg}`); logError(error, `git clean -ffd failed: ${msg}`);
warnings.push(`Failed to remove untracked files: ${msg}`); warnings.push(`Failed to remove untracked files: ${msg}`);
} }

View File

@@ -29,6 +29,7 @@ export interface AgentExecutionOptions {
credentials?: Credentials; credentials?: Credentials;
claudeCompatibleProvider?: ClaudeCompatibleProvider; claudeCompatibleProvider?: ClaudeCompatibleProvider;
mcpServers?: Record<string, unknown>; mcpServers?: Record<string, unknown>;
sdkSessionId?: string;
sdkOptions?: { sdkOptions?: {
maxTurns?: number; maxTurns?: number;
allowedTools?: string[]; allowedTools?: string[];

View File

@@ -93,6 +93,7 @@ export class AgentExecutor {
credentials, credentials,
claudeCompatibleProvider, claudeCompatibleProvider,
mcpServers, mcpServers,
sdkSessionId,
sdkOptions, sdkOptions,
} = options; } = options;
const { content: promptContent } = await buildPromptWithImages( const { content: promptContent } = await buildPromptWithImages(
@@ -129,6 +130,7 @@ export class AgentExecutor {
thinkingLevel: options.thinkingLevel, thinkingLevel: options.thinkingLevel,
credentials, credentials,
claudeCompatibleProvider, claudeCompatibleProvider,
sdkSessionId,
}; };
const featureDirForOutput = getFeatureDir(projectPath, featureId); const featureDirForOutput = getFeatureDir(projectPath, featureId);
const outputPath = path.join(featureDirForOutput, 'agent-output.md'); const outputPath = path.join(featureDirForOutput, 'agent-output.md');
@@ -217,6 +219,9 @@ export class AgentExecutor {
try { try {
const stream = provider.executeQuery(executeOptions); const stream = provider.executeQuery(executeOptions);
streamLoop: for await (const msg of stream) { streamLoop: for await (const msg of stream) {
if (msg.session_id && msg.session_id !== options.sdkSessionId) {
options.sdkSessionId = msg.session_id;
}
receivedAnyStreamMessage = true; receivedAnyStreamMessage = true;
appendRawEvent(msg); appendRawEvent(msg);
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
@@ -385,6 +390,9 @@ export class AgentExecutor {
taskCompleteDetected = false; taskCompleteDetected = false;
for await (const msg of taskStream) { for await (const msg of taskStream) {
if (msg.session_id && msg.session_id !== options.sdkSessionId) {
options.sdkSessionId = msg.session_id;
}
if (msg.type === 'assistant' && msg.message?.content) { if (msg.type === 'assistant' && msg.message?.content) {
for (const b of msg.message.content) { for (const b of msg.message.content) {
if (b.type === 'text') { if (b.type === 'text') {
@@ -599,6 +607,9 @@ export class AgentExecutor {
for await (const msg of provider.executeQuery( for await (const msg of provider.executeQuery(
this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS)
)) { )) {
if (msg.session_id && msg.session_id !== options.sdkSessionId) {
options.sdkSessionId = msg.session_id;
}
if (msg.type === 'assistant' && msg.message?.content) if (msg.type === 'assistant' && msg.message?.content)
for (const b of msg.message.content) for (const b of msg.message.content)
if (b.type === 'text') { if (b.type === 'text') {
@@ -698,6 +709,7 @@ export class AgentExecutor {
: undefined, : undefined,
credentials: o.credentials, credentials: o.credentials,
claudeCompatibleProvider: o.claudeCompatibleProvider, claudeCompatibleProvider: o.claudeCompatibleProvider,
sdkSessionId: o.sdkSessionId,
}; };
} }
@@ -717,6 +729,9 @@ export class AgentExecutor {
for await (const msg of provider.executeQuery( for await (const msg of provider.executeQuery(
this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS)
)) { )) {
if (msg.session_id && msg.session_id !== options.sdkSessionId) {
options.sdkSessionId = msg.session_id;
}
if (msg.type === 'assistant' && msg.message?.content) if (msg.type === 'assistant' && msg.message?.content)
for (const b of msg.message.content) { for (const b of msg.message.content) {
if (b.type === 'text') { if (b.type === 'text') {

View File

@@ -329,12 +329,6 @@ export class AgentService {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
// Build conversation history from existing messages BEFORE adding current message
const conversationHistory = session.messages.map((msg) => ({
role: msg.role,
content: msg.content,
}));
session.messages.push(userMessage); session.messages.push(userMessage);
session.isRunning = true; session.isRunning = true;
session.abortController = new AbortController(); session.abortController = new AbortController();
@@ -406,6 +400,7 @@ export class AgentService {
} }
} }
let combinedSystemPrompt: string | undefined;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
// Use the user's message as task context for smart memory selection // Use the user's message as task context for smart memory selection
const contextResult = await loadContextFiles({ const contextResult = await loadContextFiles({
@@ -423,7 +418,7 @@ export class AgentService {
// Build combined system prompt with base prompt and context files // Build combined system prompt with base prompt and context files
const baseSystemPrompt = await this.getSystemPrompt(); const baseSystemPrompt = await this.getSystemPrompt();
const combinedSystemPrompt = contextFilesPrompt combinedSystemPrompt = contextFilesPrompt
? `${contextFilesPrompt}\n\n${baseSystemPrompt}` ? `${contextFilesPrompt}\n\n${baseSystemPrompt}`
: baseSystemPrompt; : baseSystemPrompt;
@@ -513,6 +508,14 @@ export class AgentService {
: stripProviderPrefix(effectiveModel); : stripProviderPrefix(effectiveModel);
// Build options for provider // Build options for provider
const conversationHistory = session.messages
.slice(0, -1)
.map((msg) => ({
role: msg.role,
content: msg.content,
}))
.filter((msg) => msg.content.trim().length > 0);
const options: ExecuteOptions = { const options: ExecuteOptions = {
prompt: '', // Will be set below based on images prompt: '', // Will be set below based on images
model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1") model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1")
@@ -522,7 +525,8 @@ export class AgentService {
maxTurns: maxTurns, maxTurns: maxTurns,
allowedTools: allowedTools, allowedTools: allowedTools,
abortController: session.abortController!, abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, conversationHistory:
conversationHistory && conversationHistory.length > 0 ? conversationHistory : undefined,
settingSources: settingSources.length > 0 ? settingSources : undefined, settingSources: settingSources.length > 0 ? settingSources : undefined,
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration

View File

@@ -170,6 +170,30 @@ describe('codex-provider.ts', () => {
expect(call.args).toContain('--json'); expect(call.args).toContain('--json');
}); });
it('uses exec resume when sdkSessionId is provided', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Continue',
model: 'gpt-5.2',
cwd: '/tmp',
sdkSessionId: 'codex-session-123',
outputFormat: { type: 'json_schema', schema: { type: 'object', properties: {} } },
codexSettings: { additionalDirs: ['/extra/dir'] },
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
expect(call.args[0]).toBe('exec');
expect(call.args[1]).toBe('resume');
expect(call.args).toContain('codex-session-123');
expect(call.args).toContain('--json');
// Resume queries must not include --output-schema or --add-dir
expect(call.args).not.toContain('--output-schema');
expect(call.args).not.toContain('--add-dir');
});
it('overrides approval policy when MCP auto-approval is enabled', async () => { it('overrides approval policy when MCP auto-approval is enabled', async () => {
// Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox), // Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox),
// approval policy is bypassed, not configured via --config // approval policy is bypassed, not configured via --config

View File

@@ -1,17 +1,35 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js'; import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js';
import { collectAsyncGenerator } from '../../utils/helpers.js';
import { CopilotClient } from '@github/copilot-sdk';
const createSessionMock = vi.fn();
const resumeSessionMock = vi.fn();
function createMockSession(sessionId = 'test-session') {
let eventHandler: ((event: any) => void) | null = null;
return {
sessionId,
send: vi.fn().mockImplementation(async () => {
if (eventHandler) {
eventHandler({ type: 'assistant.message', data: { content: 'hello' } });
eventHandler({ type: 'session.idle' });
}
}),
destroy: vi.fn().mockResolvedValue(undefined),
on: vi.fn().mockImplementation((handler: (event: any) => void) => {
eventHandler = handler;
}),
};
}
// Mock the Copilot SDK // Mock the Copilot SDK
vi.mock('@github/copilot-sdk', () => ({ vi.mock('@github/copilot-sdk', () => ({
CopilotClient: vi.fn().mockImplementation(() => ({ CopilotClient: vi.fn().mockImplementation(() => ({
start: vi.fn().mockResolvedValue(undefined), start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined),
createSession: vi.fn().mockResolvedValue({ createSession: createSessionMock,
sessionId: 'test-session', resumeSession: resumeSessionMock,
send: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
}),
})), })),
})); }));
@@ -49,6 +67,16 @@ describe('copilot-provider.ts', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(CopilotClient).mockImplementation(function () {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
createSession: createSessionMock,
resumeSession: resumeSessionMock,
} as any;
});
createSessionMock.mockResolvedValue(createMockSession());
resumeSessionMock.mockResolvedValue(createMockSession('resumed-session'));
// Mock fs.existsSync for CLI path validation // Mock fs.existsSync for CLI path validation
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
@@ -514,4 +542,45 @@ describe('copilot-provider.ts', () => {
expect(todoInput.todos[0].status).toBe('completed'); expect(todoInput.todos[0].status).toBe('completed');
}); });
}); });
describe('executeQuery resume behavior', () => {
it('uses resumeSession when sdkSessionId is provided', async () => {
const results = await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Hello',
model: 'claude-sonnet-4.6',
cwd: '/tmp/project',
sdkSessionId: 'session-123',
})
);
expect(resumeSessionMock).toHaveBeenCalledWith(
'session-123',
expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true })
);
expect(createSessionMock).not.toHaveBeenCalled();
expect(results.some((msg) => msg.session_id === 'resumed-session')).toBe(true);
});
it('falls back to createSession when resumeSession fails', async () => {
resumeSessionMock.mockRejectedValueOnce(new Error('session not found'));
createSessionMock.mockResolvedValueOnce(createMockSession('fresh-session'));
const results = await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Hello',
model: 'claude-sonnet-4.6',
cwd: '/tmp/project',
sdkSessionId: 'stale-session',
})
);
expect(resumeSessionMock).toHaveBeenCalledWith(
'stale-session',
expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true })
);
expect(createSessionMock).toHaveBeenCalledTimes(1);
expect(results.some((msg) => msg.session_id === 'fresh-session')).toBe(true);
});
});
}); });

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { CursorProvider } from '@/providers/cursor-provider.js';
describe('cursor-provider.ts', () => {
describe('buildCliArgs', () => {
it('adds --resume when sdkSessionId is provided', () => {
const provider = Object.create(CursorProvider.prototype) as CursorProvider & {
cliPath?: string;
};
provider.cliPath = '/usr/local/bin/cursor-agent';
const args = provider.buildCliArgs({
prompt: 'Continue the task',
model: 'gpt-5',
cwd: '/tmp/project',
sdkSessionId: 'cursor-session-123',
});
const resumeIndex = args.indexOf('--resume');
expect(resumeIndex).toBeGreaterThan(-1);
expect(args[resumeIndex + 1]).toBe('cursor-session-123');
});
it('does not add --resume when sdkSessionId is omitted', () => {
const provider = Object.create(CursorProvider.prototype) as CursorProvider & {
cliPath?: string;
};
provider.cliPath = '/usr/local/bin/cursor-agent';
const args = provider.buildCliArgs({
prompt: 'Start a new task',
model: 'gpt-5',
cwd: '/tmp/project',
});
expect(args).not.toContain('--resume');
});
});
});

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GeminiProvider } from '@/providers/gemini-provider.js';
describe('gemini-provider.ts', () => {
let provider: GeminiProvider;
beforeEach(() => {
provider = new GeminiProvider();
});
describe('buildCliArgs', () => {
it('should include --resume when sdkSessionId is provided', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/project',
sdkSessionId: 'gemini-session-123',
});
const resumeIndex = args.indexOf('--resume');
expect(resumeIndex).toBeGreaterThan(-1);
expect(args[resumeIndex + 1]).toBe('gemini-session-123');
});
it('should not include --resume when sdkSessionId is missing', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/project',
});
expect(args).not.toContain('--resume');
});
});
});

View File

@@ -303,6 +303,36 @@ describe('agent-service.ts', () => {
expect(fs.writeFile).toHaveBeenCalled(); expect(fs.writeFile).toHaveBeenCalled();
}); });
it('should include context/history preparation for Gemini requests', async () => {
let capturedOptions: any;
const mockProvider = {
getName: () => 'gemini',
executeQuery: async function* (options: any) {
capturedOptions = options;
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModelName).mockReturnValue('gemini');
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: 'Hello',
hasImages: false,
});
await service.sendMessage({
sessionId: 'session-1',
message: 'Hello',
model: 'gemini-2.5-flash',
});
expect(contextLoader.loadContextFiles).toHaveBeenCalled();
expect(capturedOptions).toBeDefined();
});
}); });
describe('stopExecution', () => { describe('stopExecution', () => {

View File

@@ -1,6 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import type { PhaseModelEntry } from '@automaker/types';
import { useElectronAgent } from '@/hooks/use-electron-agent'; import { useElectronAgent } from '@/hooks/use-electron-agent';
import { SessionManager } from '@/components/session-manager'; import { SessionManager } from '@/components/session-manager';
@@ -46,8 +45,6 @@ export function AgentView() {
return () => window.removeEventListener('resize', updateVisibility); return () => window.removeEventListener('resize', updateVisibility);
}, []); }, []);
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
// Input ref for auto-focus // Input ref for auto-focus
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -57,10 +54,12 @@ export function AgentView() {
const createSessionInFlightRef = useRef(false); const createSessionInFlightRef = useRef(false);
// Session management hook - scoped to current worktree // Session management hook - scoped to current worktree
const { currentSessionId, handleSelectSession } = useAgentSession({ // Also handles model selection persistence per session
projectPath: currentProject?.path, const { currentSessionId, handleSelectSession, modelSelection, setModelSelection } =
workingDirectory: effectiveWorkingDirectory, useAgentSession({
}); projectPath: currentProject?.path,
workingDirectory: effectiveWorkingDirectory,
});
// Use the Electron agent hook (only if we have a session) // Use the Electron agent hook (only if we have a session)
const { const {

View File

@@ -1,9 +1,14 @@
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import type { PhaseModelEntry } from '@automaker/types';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('AgentSession'); const logger = createLogger('AgentSession');
// Default model selection when none is persisted
const DEFAULT_MODEL_SELECTION: PhaseModelEntry = { model: 'claude-sonnet' };
interface UseAgentSessionOptions { interface UseAgentSessionOptions {
projectPath: string | undefined; projectPath: string | undefined;
workingDirectory?: string; // Current worktree path for per-worktree session persistence workingDirectory?: string; // Current worktree path for per-worktree session persistence
@@ -12,14 +17,31 @@ interface UseAgentSessionOptions {
interface UseAgentSessionResult { interface UseAgentSessionResult {
currentSessionId: string | null; currentSessionId: string | null;
handleSelectSession: (sessionId: string | null) => void; handleSelectSession: (sessionId: string | null) => void;
// Model selection persistence
modelSelection: PhaseModelEntry;
setModelSelection: (model: PhaseModelEntry) => void;
} }
export function useAgentSession({ export function useAgentSession({
projectPath, projectPath,
workingDirectory, workingDirectory,
}: UseAgentSessionOptions): UseAgentSessionResult { }: UseAgentSessionOptions): UseAgentSessionResult {
const { setLastSelectedSession, getLastSelectedSession } = useAppStore(); const {
setLastSelectedSession,
getLastSelectedSession,
setAgentModelForSession,
getAgentModelForSession,
} = useAppStore(
useShallow((state) => ({
setLastSelectedSession: state.setLastSelectedSession,
getLastSelectedSession: state.getLastSelectedSession,
setAgentModelForSession: state.setAgentModelForSession,
getAgentModelForSession: state.getAgentModelForSession,
}))
);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null); const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [modelSelection, setModelSelectionState] =
useState<PhaseModelEntry>(DEFAULT_MODEL_SELECTION);
// Track if initial session has been loaded // Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false); const initialSessionLoadedRef = useRef(false);
@@ -27,6 +49,22 @@ export function useAgentSession({
// Use workingDirectory as the persistence key so sessions are scoped per worktree // Use workingDirectory as the persistence key so sessions are scoped per worktree
const persistenceKey = workingDirectory || projectPath; const persistenceKey = workingDirectory || projectPath;
/**
* Fetch persisted model for sessionId and update local state, or fall back to default.
*/
const restoreModelForSession = useCallback(
(sessionId: string) => {
const persistedModel = getAgentModelForSession(sessionId);
if (persistedModel) {
logger.debug('Restoring model selection for session:', sessionId, persistedModel);
setModelSelectionState(persistedModel);
} else {
setModelSelectionState(DEFAULT_MODEL_SELECTION);
}
},
[getAgentModelForSession]
);
// Handle session selection with persistence // Handle session selection with persistence
const handleSelectSession = useCallback( const handleSelectSession = useCallback(
(sessionId: string | null) => { (sessionId: string | null) => {
@@ -35,16 +73,52 @@ export function useAgentSession({
if (persistenceKey) { if (persistenceKey) {
setLastSelectedSession(persistenceKey, sessionId); setLastSelectedSession(persistenceKey, sessionId);
} }
// Restore model selection for this session if available
if (sessionId) {
restoreModelForSession(sessionId);
}
}, },
[persistenceKey, setLastSelectedSession] [persistenceKey, setLastSelectedSession, restoreModelForSession]
); );
// Wrapper for setModelSelection that also persists
const setModelSelection = useCallback(
(model: PhaseModelEntry) => {
setModelSelectionState(model);
// Persist model selection for current session.
// If currentSessionId is null (no active session), we only update local state
// and skip persistence — this is intentional because the model picker should be
// disabled (or hidden) in the UI whenever there is no active session, so this
// path is only reached if the UI allows selection before a session is established.
if (currentSessionId) {
setAgentModelForSession(currentSessionId, model);
}
},
[currentSessionId, setAgentModelForSession]
);
// Track the previous persistence key to detect actual changes
const prevPersistenceKeyRef = useRef(persistenceKey);
// Restore last selected session when switching to Agent view or when worktree changes // Restore last selected session when switching to Agent view or when worktree changes
useEffect(() => { useEffect(() => {
if (!persistenceKey) { // Detect if persistenceKey actually changed (worktree/project switch)
// No project, reset const persistenceKeyChanged = prevPersistenceKeyRef.current !== persistenceKey;
setCurrentSessionId(null);
if (persistenceKeyChanged) {
// Reset state when switching worktree/project
prevPersistenceKeyRef.current = persistenceKey;
initialSessionLoadedRef.current = false; initialSessionLoadedRef.current = false;
setCurrentSessionId(null);
setModelSelectionState(DEFAULT_MODEL_SELECTION);
if (!persistenceKey) {
// No project, nothing to restore
return;
}
}
if (!persistenceKey) {
return; return;
} }
@@ -54,19 +128,17 @@ export function useAgentSession({
const lastSessionId = getLastSelectedSession(persistenceKey); const lastSessionId = getLastSelectedSession(persistenceKey);
if (lastSessionId) { if (lastSessionId) {
logger.info('Restoring last selected session:', lastSessionId); logger.debug('Restoring last selected session:', lastSessionId);
setCurrentSessionId(lastSessionId); setCurrentSessionId(lastSessionId);
// Also restore model selection for this session
restoreModelForSession(lastSessionId);
} }
}, [persistenceKey, getLastSelectedSession]); }, [persistenceKey, getLastSelectedSession, restoreModelForSession]);
// Reset when worktree/project changes - clear current session and allow restore
useEffect(() => {
initialSessionLoadedRef.current = false;
setCurrentSessionId(null);
}, [persistenceKey]);
return { return {
currentSessionId, currentSessionId,
handleSelectSession, handleSelectSession,
modelSelection,
setModelSelection,
}; };
} }

View File

@@ -116,7 +116,6 @@ export function BoardView() {
setPendingPlanApproval, setPendingPlanApproval,
updateFeature, updateFeature,
batchUpdateFeatures, batchUpdateFeatures,
getCurrentWorktree,
setCurrentWorktree, setCurrentWorktree,
getWorktrees, getWorktrees,
setWorktrees, setWorktrees,
@@ -135,7 +134,6 @@ export function BoardView() {
setPendingPlanApproval: state.setPendingPlanApproval, setPendingPlanApproval: state.setPendingPlanApproval,
updateFeature: state.updateFeature, updateFeature: state.updateFeature,
batchUpdateFeatures: state.batchUpdateFeatures, batchUpdateFeatures: state.batchUpdateFeatures,
getCurrentWorktree: state.getCurrentWorktree,
setCurrentWorktree: state.setCurrentWorktree, setCurrentWorktree: state.setCurrentWorktree,
getWorktrees: state.getWorktrees, getWorktrees: state.getWorktrees,
setWorktrees: state.setWorktrees, setWorktrees: state.setWorktrees,
@@ -444,9 +442,17 @@ export function BoardView() {
[batchResetBranchFeatures] [batchResetBranchFeatures]
); );
// Get current worktree info (path) for filtering features const currentProjectPath = currentProject?.path;
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null; // Get current worktree info (path/branch) for filtering features.
// Subscribe to the selected project's current worktree value directly so worktree
// switches trigger an immediate re-render and instant kanban/list re-filtering.
const currentWorktreeInfo = useAppStore(
useCallback(
(s) => (currentProjectPath ? (s.currentWorktreeByProject[currentProjectPath] ?? null) : null),
[currentProjectPath]
)
);
const currentWorktreePath = currentWorktreeInfo?.path ?? null; const currentWorktreePath = currentWorktreeInfo?.path ?? null;
// Select worktrees for the current project directly from the store. // Select worktrees for the current project directly from the store.
@@ -455,7 +461,6 @@ export function BoardView() {
// object, causing unnecessary re-renders that cascaded into selectedWorktree → // object, causing unnecessary re-renders that cascaded into selectedWorktree →
// useAutoMode → refreshStatus → setAutoModeRunning → store update → re-render loop // useAutoMode → refreshStatus → setAutoModeRunning → store update → re-render loop
// that could trigger React error #185 on initial project open). // that could trigger React error #185 on initial project open).
const currentProjectPath = currentProject?.path;
const worktrees = useAppStore( const worktrees = useAppStore(
useCallback( useCallback(
(s) => (s) =>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -30,13 +30,17 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Upload, Upload,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { resolveModelString } from '@automaker/model-resolver';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path'; import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import type { FileStatus, MergeStateInfo } from '@/types/electron'; import type { FileStatus, MergeStateInfo } from '@/types/electron';
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils'; import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
@@ -206,6 +210,11 @@ export function CommitWorktreeDialog({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages); const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
// Commit message model override
const commitModelOverride = useModelOverride({ phase: 'commitMessageModel' });
const { effectiveModel: commitEffectiveModel, effectiveModelEntry: commitEffectiveModelEntry } =
commitModelOverride;
// File selection state // File selection state
const [files, setFiles] = useState<FileStatus[]>([]); const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState(''); const [diffContent, setDiffContent] = useState('');
@@ -532,6 +541,46 @@ export function CommitWorktreeDialog({
} }
}; };
// Generate AI commit message
const generateCommitMessage = useCallback(async () => {
if (!worktree) return;
setIsGenerating(true);
try {
const resolvedCommitModel = resolveModelString(commitEffectiveModel);
const api = getHttpApiClient();
const result = await api.worktree.generateCommitMessage(
worktree.path,
resolvedCommitModel,
commitEffectiveModelEntry?.thinkingLevel,
commitEffectiveModelEntry?.providerId
);
if (result.success && result.message) {
setMessage(result.message);
} else {
console.warn('Failed to generate commit message:', result.error);
toast.error('Failed to generate commit message', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
console.warn('Error generating commit message:', err);
toast.error('Failed to generate commit message', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsGenerating(false);
}
}, [worktree, commitEffectiveModel, commitEffectiveModelEntry]);
// Keep a stable ref to generateCommitMessage so the open-dialog effect
// doesn't re-fire (and erase user edits) when the model override changes.
const generateCommitMessageRef = useRef(generateCommitMessage);
useEffect(() => {
generateCommitMessageRef.current = generateCommitMessage;
});
// Generate AI commit message when dialog opens (if enabled) // Generate AI commit message when dialog opens (if enabled)
useEffect(() => { useEffect(() => {
if (open && worktree) { if (open && worktree) {
@@ -543,45 +592,7 @@ export function CommitWorktreeDialog({
return; return;
} }
setIsGenerating(true); generateCommitMessageRef.current();
let cancelled = false;
const generateMessage = async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.generateCommitMessage) {
if (!cancelled) {
setIsGenerating(false);
}
return;
}
const result = await api.worktree.generateCommitMessage(worktree.path);
if (cancelled) return;
if (result.success && result.message) {
setMessage(result.message);
} else {
console.warn('Failed to generate commit message:', result.error);
setMessage('');
}
} catch (err) {
if (cancelled) return;
console.warn('Error generating commit message:', err);
setMessage('');
} finally {
if (!cancelled) {
setIsGenerating(false);
}
}
};
generateMessage();
return () => {
cancelled = true;
};
} }
}, [open, worktree, enableAiCommitMessages]); }, [open, worktree, enableAiCommitMessages]);
@@ -589,12 +600,12 @@ export function CommitWorktreeDialog({
const allSelected = selectedFiles.size === files.length && files.length > 0; const allSelected = selectedFiles.size === files.length && files.length > 0;
// Prevent the dialog from being dismissed while a push is in progress. // Prevent the dialog from being dismissed while a push or generation is in progress.
// Overlay clicks and Escape key both route through onOpenChange(false); we // Overlay clicks and Escape key both route through onOpenChange(false); we
// intercept those here so the UI stays open until the push completes. // intercept those here so the UI stays open until the operation completes.
const handleOpenChange = (nextOpen: boolean) => { const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && isPushing) { if (!nextOpen && (isLoading || isPushing || isGenerating)) {
// Ignore close requests during an active push. // Ignore close requests during an active commit, push, or generation.
return; return;
} }
onOpenChange(nextOpen); onOpenChange(nextOpen);
@@ -813,15 +824,46 @@ export function CommitWorktreeDialog({
{/* Commit Message */} {/* Commit Message */}
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label htmlFor="commit-message" className="flex items-center gap-2"> <div className="flex items-center justify-between">
Commit Message <Label htmlFor="commit-message" className="flex items-center gap-2">
{isGenerating && ( Commit Message
<span className="flex items-center gap-1 text-xs text-muted-foreground"> {isGenerating && (
<Sparkles className="w-3 h-3 animate-pulse" /> <span className="flex items-center gap-1 text-xs text-muted-foreground">
Generating... <Sparkles className="w-3 h-3 animate-pulse" />
</span> Generating...
)} </span>
</Label> )}
</Label>
<div className="flex items-center gap-1">
{enableAiCommitMessages && (
<>
<Button
variant="ghost"
size="sm"
onClick={generateCommitMessage}
disabled={isGenerating || isLoading}
className="h-6 px-2 text-xs"
title="Regenerate commit message"
>
{isGenerating ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Regenerate
</Button>
<ModelOverrideTrigger
currentModelEntry={commitModelOverride.effectiveModelEntry}
onModelChange={commitModelOverride.setOverride}
phase="commitMessageModel"
isOverridden={commitModelOverride.isOverridden}
size="sm"
variant="icon"
/>
</>
)}
</div>
</div>
<Textarea <Textarea
id="commit-message" id="commit-message"
placeholder={ placeholder={

View File

@@ -26,6 +26,7 @@ import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useWorktreeBranches } from '@/hooks/queries'; import { useWorktreeBranches } from '@/hooks/queries';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
interface RemoteInfo { interface RemoteInfo {
name: string; name: string;
@@ -92,6 +93,9 @@ export function CreatePRDialog({
// Generate description state // Generate description state
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false); const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
// PR description model override
const prDescriptionModelOverride = useModelOverride({ phase: 'prDescriptionModel' });
// Use React Query for branch fetching - only enabled when dialog is open // Use React Query for branch fetching - only enabled when dialog is open
const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches( const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches(
open ? worktree?.path : undefined, open ? worktree?.path : undefined,
@@ -306,7 +310,13 @@ export function CreatePRDialog({
resolvedRef !== baseBranch && resolvedRef.includes('/') resolvedRef !== baseBranch && resolvedRef.includes('/')
? resolvedRef.substring(resolvedRef.indexOf('/') + 1) ? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
: resolvedRef; : resolvedRef;
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi); const result = await api.worktree.generatePRDescription(
worktree.path,
branchNameForApi,
prDescriptionModelOverride.effectiveModel,
prDescriptionModelOverride.effectiveModelEntry.thinkingLevel,
prDescriptionModelOverride.effectiveModelEntry.providerId
);
if (result.success) { if (result.success) {
if (result.title) { if (result.title) {
@@ -587,30 +597,40 @@ export function CreatePRDialog({
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="pr-title">PR Title</Label> <Label htmlFor="pr-title">PR Title</Label>
<Button <div className="flex items-center gap-1">
variant="ghost" <Button
size="sm" variant="ghost"
onClick={handleGenerateDescription} size="sm"
disabled={isGeneratingDescription || isLoading} onClick={handleGenerateDescription}
className="h-6 px-2 text-xs" disabled={isGeneratingDescription || isLoading}
title={ className="h-6 px-2 text-xs"
worktree.hasChanges title={
? 'Generate title and description from commits and uncommitted changes' worktree.hasChanges
: 'Generate title and description from commits' ? 'Generate title and description from commits and uncommitted changes'
} : 'Generate title and description from commits'
> }
{isGeneratingDescription ? ( >
<> {isGeneratingDescription ? (
<Spinner size="xs" className="mr-1" /> <>
Generating... <Spinner size="xs" className="mr-1" />
</> Generating...
) : ( </>
<> ) : (
<Sparkles className="w-3 h-3 mr-1" /> <>
Generate with AI <Sparkles className="w-3 h-3 mr-1" />
</> Generate with AI
)} </>
</Button> )}
</Button>
<ModelOverrideTrigger
currentModelEntry={prDescriptionModelOverride.effectiveModelEntry}
onModelChange={prDescriptionModelOverride.setOverride}
phase="prDescriptionModel"
isOverridden={prDescriptionModelOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</div> </div>
<Input <Input
id="pr-title" id="pr-title"

View File

@@ -109,6 +109,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'projectHistory', 'projectHistory',
'projectHistoryIndex', 'projectHistoryIndex',
'lastSelectedSessionByProject', 'lastSelectedSessionByProject',
'agentModelBySession',
'currentWorktreeByProject', 'currentWorktreeByProject',
// Codex CLI Settings // Codex CLI Settings
'codexAutoLoadAgents', 'codexAutoLoadAgents',
@@ -173,6 +174,17 @@ function getSettingsFieldValue(
} }
return persistedSettings; return persistedSettings;
} }
if (field === 'agentModelBySession') {
// Cap to the 50 most-recently-inserted session entries to prevent unbounded growth.
// agentModelBySession grows by one entry per agent session — without pruning this
// will bloat settings.json, every debounced sync payload, and the localStorage cache.
const map = appState.agentModelBySession as Record<string, unknown>;
const MAX_ENTRIES = 50;
const entries = Object.entries(map);
if (entries.length <= MAX_ENTRIES) return map;
// Keep the last MAX_ENTRIES entries (insertion-order approximation for recency)
return Object.fromEntries(entries.slice(-MAX_ENTRIES));
}
return appState[field as keyof typeof appState]; return appState[field as keyof typeof appState];
} }
@@ -806,6 +818,13 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
projectHistory: serverSettings.projectHistory, projectHistory: serverSettings.projectHistory,
projectHistoryIndex: serverSettings.projectHistoryIndex, projectHistoryIndex: serverSettings.projectHistoryIndex,
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject, lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
agentModelBySession: serverSettings.agentModelBySession
? Object.fromEntries(
Object.entries(serverSettings.agentModelBySession as Record<string, unknown>).map(
([sessionId, entry]) => [sessionId, migratePhaseModelEntry(entry)]
)
)
: currentAppState.agentModelBySession,
// Sanitize: only restore entries with path === null (main branch). // Sanitize: only restore entries with path === null (main branch).
// Non-null paths may reference deleted worktrees, causing crash loops. // Non-null paths may reference deleted worktrees, causing crash loops.
currentWorktreeByProject: sanitizeWorktreeByProject( currentWorktreeByProject: sanitizeWorktreeByProject(

View File

@@ -2204,10 +2204,32 @@ export class HttpApiClient implements ElectronAPI {
}), }),
commit: (worktreePath: string, message: string, files?: string[]) => commit: (worktreePath: string, message: string, files?: string[]) =>
this.post('/api/worktree/commit', { worktreePath, message, files }), this.post('/api/worktree/commit', { worktreePath, message, files }),
generateCommitMessage: (worktreePath: string) => generateCommitMessage: (
this.post('/api/worktree/generate-commit-message', { worktreePath }), worktreePath: string,
generatePRDescription: (worktreePath: string, baseBranch?: string) => model?: string,
this.post('/api/worktree/generate-pr-description', { worktreePath, baseBranch }), thinkingLevel?: string,
providerId?: string
) =>
this.post('/api/worktree/generate-commit-message', {
worktreePath,
model,
thinkingLevel,
providerId,
}),
generatePRDescription: (
worktreePath: string,
baseBranch?: string,
model?: string,
thinkingLevel?: string,
providerId?: string
) =>
this.post('/api/worktree/generate-pr-description', {
worktreePath,
baseBranch,
model,
thinkingLevel,
providerId,
}),
push: (worktreePath: string, force?: boolean, remote?: string, autoResolve?: boolean) => push: (worktreePath: string, force?: boolean, remote?: string, autoResolve?: boolean) =>
this.post('/api/worktree/push', { worktreePath, force, remote, autoResolve }), this.post('/api/worktree/push', { worktreePath, force, remote, autoResolve }),
sync: (worktreePath: string, remote?: string) => sync: (worktreePath: string, remote?: string) =>

View File

@@ -3,10 +3,14 @@
*/ */
/** /**
* Drop currentWorktreeByProject entries with non-null paths. * Validate and sanitize currentWorktreeByProject entries.
* Non-null paths reference worktree directories that may have been deleted, *
* and restoring them causes crash loops (board renders invalid worktree * Keeps all valid entries (both main branch and feature worktrees).
* -> error boundary reloads -> restores same stale path). * The validation against actual worktrees happens in use-worktrees.ts
* which resets to main branch if the selected worktree no longer exists.
*
* Only drops entries with invalid structure (not an object, missing/invalid
* path or branch).
*/ */
export function sanitizeWorktreeByProject( export function sanitizeWorktreeByProject(
raw: Record<string, { path: string | null; branch: string }> | undefined raw: Record<string, { path: string | null; branch: string }> | undefined
@@ -14,11 +18,13 @@ export function sanitizeWorktreeByProject(
if (!raw) return {}; if (!raw) return {};
const sanitized: Record<string, { path: string | null; branch: string }> = {}; const sanitized: Record<string, { path: string | null; branch: string }> = {};
for (const [projectPath, worktree] of Object.entries(raw)) { for (const [projectPath, worktree] of Object.entries(raw)) {
// Only validate structure - keep both null (main) and non-null (worktree) paths
// Runtime validation in use-worktrees.ts handles deleted worktrees
if ( if (
typeof worktree === 'object' && typeof worktree === 'object' &&
worktree !== null && worktree !== null &&
'path' in worktree && typeof worktree.branch === 'string' &&
worktree.path === null (worktree.path === null || typeof worktree.path === 'string')
) { ) {
sanitized[projectPath] = worktree; sanitized[projectPath] = worktree;
} }

View File

@@ -275,6 +275,7 @@ const initialState: AppState = {
collapsedNavSections: cachedUI.collapsedNavSections, collapsedNavSections: cachedUI.collapsedNavSections,
mobileSidebarHidden: false, mobileSidebarHidden: false,
lastSelectedSessionByProject: {}, lastSelectedSessionByProject: {},
agentModelBySession: {},
theme: getStoredTheme() || 'dark', theme: getStoredTheme() || 'dark',
fontFamilySans: getStoredFontSans(), fontFamilySans: getStoredFontSans(),
fontFamilyMono: getStoredFontMono(), fontFamilyMono: getStoredFontMono(),
@@ -962,11 +963,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
), ),
})), })),
deleteChatSession: (sessionId) => deleteChatSession: (sessionId) =>
set((state) => ({ set((state) => {
chatSessions: state.chatSessions.filter((s) => s.id !== sessionId), const { [sessionId]: _removed, ...remainingAgentModels } = state.agentModelBySession;
currentChatSession: return {
state.currentChatSession?.id === sessionId ? null : state.currentChatSession, chatSessions: state.chatSessions.filter((s) => s.id !== sessionId),
})), currentChatSession:
state.currentChatSession?.id === sessionId ? null : state.currentChatSession,
agentModelBySession: remainingAgentModels,
};
}),
setChatHistoryOpen: (open) => set({ chatHistoryOpen: open }), setChatHistoryOpen: (open) => set({ chatHistoryOpen: open }),
toggleChatHistory: () => set((state) => ({ chatHistoryOpen: !state.chatHistoryOpen })), toggleChatHistory: () => set((state) => ({ chatHistoryOpen: !state.chatHistoryOpen })),
@@ -1598,6 +1603,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})), })),
getLastSelectedSession: (projectPath) => get().lastSelectedSessionByProject[projectPath] ?? null, getLastSelectedSession: (projectPath) => get().lastSelectedSessionByProject[projectPath] ?? null,
// Agent model selection actions
setAgentModelForSession: (sessionId, model) =>
set((state) => ({
agentModelBySession: {
...state.agentModelBySession,
[sessionId]: model,
},
})),
getAgentModelForSession: (sessionId) => get().agentModelBySession[sessionId] ?? null,
// Board Background actions // Board Background actions
setBoardBackground: (projectPath, imagePath) => setBoardBackground: (projectPath, imagePath) =>
set((state) => ({ set((state) => ({

View File

@@ -83,6 +83,8 @@ export interface AppState {
// Agent Session state (per-project, keyed by project path) // Agent Session state (per-project, keyed by project path)
lastSelectedSessionByProject: Record<string, string>; // projectPath -> sessionId lastSelectedSessionByProject: Record<string, string>; // projectPath -> sessionId
// Agent model selection (per-session, keyed by sessionId)
agentModelBySession: Record<string, PhaseModelEntry>; // sessionId -> model selection
// Theme // Theme
theme: ThemeMode; theme: ThemeMode;
@@ -669,6 +671,9 @@ export interface AppActions {
// Agent Session actions // Agent Session actions
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void; setLastSelectedSession: (projectPath: string, sessionId: string | null) => void;
getLastSelectedSession: (projectPath: string) => string | null; getLastSelectedSession: (projectPath: string) => string | null;
// Agent model selection actions
setAgentModelForSession: (sessionId: string, model: PhaseModelEntry) => void;
getAgentModelForSession: (sessionId: string) => PhaseModelEntry | null;
// Board Background actions // Board Background actions
setBoardBackground: (projectPath: string, imagePath: string | null) => void; setBoardBackground: (projectPath: string, imagePath: string | null) => void;

View File

@@ -81,6 +81,38 @@ export const useUICacheStore = create<UICacheState & UICacheActions>()(
) )
); );
/**
* Check whether an unknown value is a valid cached worktree entry.
* Accepts objects with a non-empty string branch and a path that is null or a string.
*/
function isValidCachedWorktreeEntry(
worktree: unknown
): worktree is { path: string | null; branch: string } {
return (
typeof worktree === 'object' &&
worktree !== null &&
typeof (worktree as Record<string, unknown>).branch === 'string' &&
((worktree as Record<string, unknown>).branch as string).trim().length > 0 &&
((worktree as Record<string, unknown>).path === null ||
typeof (worktree as Record<string, unknown>).path === 'string')
);
}
/**
* Filter a raw worktree map, discarding entries that fail structural validation.
*/
function sanitizeCachedWorktreeByProject(
raw: Record<string, unknown>
): Record<string, { path: string | null; branch: string }> {
const sanitized: Record<string, { path: string | null; branch: string }> = {};
for (const [key, worktree] of Object.entries(raw)) {
if (isValidCachedWorktreeEntry(worktree)) {
sanitized[key] = worktree;
}
}
return sanitized;
}
/** /**
* Sync critical UI state from the main app store to the UI cache. * Sync critical UI state from the main app store to the UI cache.
* Call this whenever the app store changes to keep the cache up to date. * Call this whenever the app store changes to keep the cache up to date.
@@ -114,24 +146,14 @@ export function syncUICache(appState: {
update.cachedCollapsedNavSections = appState.collapsedNavSections; update.cachedCollapsedNavSections = appState.collapsedNavSections;
} }
if ('currentWorktreeByProject' in appState && appState.currentWorktreeByProject) { if ('currentWorktreeByProject' in appState && appState.currentWorktreeByProject) {
// Sanitize on write: only persist entries where path is null (main branch). // Persist all valid worktree selections (both main branch and feature worktrees).
// Non-null paths point to worktree directories on disk that may be deleted // Validation against actual worktrees happens at restore time in:
// while the app is not running. Persisting stale paths can cause crash loops // 1. restoreFromUICache() - early restore with validation
// on restore (the board renders with an invalid selection, the error boundary // 2. use-worktrees.ts - runtime validation that resets to main if deleted
// reloads, which restores the same bad cache). This mirrors the sanitization // This allows users to have their feature worktree selection persist across refreshes.
// in restoreFromUICache() for defense-in-depth. update.cachedCurrentWorktreeByProject = sanitizeCachedWorktreeByProject(
const sanitized: Record<string, { path: string | null; branch: string }> = {}; appState.currentWorktreeByProject as Record<string, unknown>
for (const [projectPath, worktree] of Object.entries(appState.currentWorktreeByProject)) { );
if (
typeof worktree === 'object' &&
worktree !== null &&
'path' in worktree &&
worktree.path === null
) {
sanitized[projectPath] = worktree;
}
}
update.cachedCurrentWorktreeByProject = sanitized;
} }
if (Object.keys(update).length > 0) { if (Object.keys(update).length > 0) {
@@ -178,33 +200,18 @@ export function restoreFromUICache(
// Restore last selected worktree per project so the board doesn't // Restore last selected worktree per project so the board doesn't
// reset to main branch after PWA memory eviction or tab discard. // reset to main branch after PWA memory eviction or tab discard.
// //
// IMPORTANT: Only restore entries where path is null (main branch selection). // Restore all valid worktree selections (both main branch and feature worktrees).
// Non-null paths point to worktree directories on disk that may have been // The validation effect in use-worktrees.ts will handle resetting to main branch
// deleted while the PWA was evicted. Restoring a stale worktree path causes // if the cached worktree no longer exists when worktree data loads.
// the board to render with an invalid selection, and if the server can't
// validate it fast enough, the app enters an unrecoverable crash loop
// (the error boundary reloads, which restores the same bad cache).
// Main branch (path=null) is always valid and safe to restore.
if ( if (
cache.cachedCurrentWorktreeByProject && cache.cachedCurrentWorktreeByProject &&
Object.keys(cache.cachedCurrentWorktreeByProject).length > 0 Object.keys(cache.cachedCurrentWorktreeByProject).length > 0
) { ) {
const sanitized: Record<string, { path: string | null; branch: string }> = {}; // Validate structure only - keep both null (main) and non-null (worktree) paths
for (const [projectPath, worktree] of Object.entries(cache.cachedCurrentWorktreeByProject)) { // Runtime validation in use-worktrees.ts handles deleted worktrees gracefully
if ( const sanitized = sanitizeCachedWorktreeByProject(
typeof worktree === 'object' && cache.cachedCurrentWorktreeByProject as Record<string, unknown>
worktree !== null && );
'path' in worktree &&
worktree.path === null
) {
// Main branch selection — always safe to restore
sanitized[projectPath] = worktree;
}
// Non-null paths are dropped; the app will re-discover actual worktrees
// from the server and the validation effect in use-worktrees will handle
// resetting to main if the cached worktree no longer exists.
// Null/malformed entries are also dropped to prevent crashes.
}
if (Object.keys(sanitized).length > 0) { if (Object.keys(sanitized).length > 0) {
stateUpdate.currentWorktreeByProject = sanitized; stateUpdate.currentWorktreeByProject = sanitized;
} }

View File

@@ -959,7 +959,12 @@ export interface WorktreeAPI {
}>; }>;
// Generate an AI commit message from git diff // Generate an AI commit message from git diff
generateCommitMessage: (worktreePath: string) => Promise<{ generateCommitMessage: (
worktreePath: string,
model?: string,
thinkingLevel?: string,
providerId?: string
) => Promise<{
success: boolean; success: boolean;
message?: string; message?: string;
error?: string; error?: string;
@@ -968,7 +973,10 @@ export interface WorktreeAPI {
// Generate an AI PR title and description from branch diff // Generate an AI PR title and description from branch diff
generatePRDescription: ( generatePRDescription: (
worktreePath: string, worktreePath: string,
baseBranch?: string baseBranch?: string,
model?: string,
thinkingLevel?: string,
providerId?: string
) => Promise<{ ) => Promise<{
success: boolean; success: boolean;
title?: string; title?: string;

View File

@@ -788,6 +788,8 @@ export interface PhaseModelConfig {
// Quick tasks - commit messages // Quick tasks - commit messages
/** Model for generating git commit messages from diffs */ /** Model for generating git commit messages from diffs */
commitMessageModel: PhaseModelEntry; commitMessageModel: PhaseModelEntry;
/** Model for generating pull request descriptions from branch diffs */
prDescriptionModel: PhaseModelEntry;
} }
/** Keys of PhaseModelConfig for type-safe access */ /** Keys of PhaseModelConfig for type-safe access */
@@ -1189,6 +1191,8 @@ export interface GlobalSettings {
// Session Tracking // Session Tracking
/** Maps project path -> last selected session ID in that project */ /** Maps project path -> last selected session ID in that project */
lastSelectedSessionByProject: Record<string, string>; lastSelectedSessionByProject: Record<string, string>;
/** Maps session ID -> persisted model selection for that session */
agentModelBySession?: Record<string, PhaseModelEntry>;
// Worktree Selection Tracking // Worktree Selection Tracking
/** Maps project path -> last selected worktree (path + branch) for restoring on PWA reload */ /** Maps project path -> last selected worktree (path + branch) for restoring on PWA reload */
@@ -1567,6 +1571,8 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
// Commit messages - use fast model for speed // Commit messages - use fast model for speed
commitMessageModel: { model: 'claude-haiku' }, commitMessageModel: { model: 'claude-haiku' },
// PR descriptions - use balanced model for better quality descriptions
prDescriptionModel: { model: 'claude-sonnet' },
}; };
/** Current version of the global settings schema */ /** Current version of the global settings schema */