mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
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:
@@ -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
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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('-');
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
39
apps/server/tests/unit/providers/cursor-provider.test.ts
Normal file
39
apps/server/tests/unit/providers/cursor-provider.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
35
apps/server/tests/unit/providers/gemini-provider.test.ts
Normal file
35
apps/server/tests/unit/providers/gemini-provider.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,7 +54,9 @@ 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
|
||||||
|
const { currentSessionId, handleSelectSession, modelSelection, setModelSelection } =
|
||||||
|
useAgentSession({
|
||||||
projectPath: currentProject?.path,
|
projectPath: currentProject?.path,
|
||||||
workingDirectory: effectiveWorkingDirectory,
|
workingDirectory: effectiveWorkingDirectory,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,6 +824,7 @@ export function CommitWorktreeDialog({
|
|||||||
|
|
||||||
{/* Commit Message */}
|
{/* Commit Message */}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="commit-message" className="flex items-center gap-2">
|
<Label htmlFor="commit-message" className="flex items-center gap-2">
|
||||||
Commit Message
|
Commit Message
|
||||||
{isGenerating && (
|
{isGenerating && (
|
||||||
@@ -822,6 +834,36 @@ export function CommitWorktreeDialog({
|
|||||||
</span>
|
</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={
|
||||||
|
|||||||
@@ -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,6 +597,7 @@ 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>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -611,6 +622,15 @@ export function CreatePRDialog({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
const { [sessionId]: _removed, ...remainingAgentModels } = state.agentModelBySession;
|
||||||
|
return {
|
||||||
chatSessions: state.chatSessions.filter((s) => s.id !== sessionId),
|
chatSessions: state.chatSessions.filter((s) => s.id !== sessionId),
|
||||||
currentChatSession:
|
currentChatSession:
|
||||||
state.currentChatSession?.id === sessionId ? null : state.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) => ({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
12
apps/ui/src/types/electron.d.ts
vendored
12
apps/ui/src/types/electron.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user