Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4ab5427
fix: enable sidebar expand and project switching on mobile
Jan 10, 2026
acce06b
Fix sidebar lables not showign up
Jan 11, 2026
d2c7a9e
Fix model selector on mobile
Jan 12, 2026
e56db23
feat: Add AI-generated commit messages
Jan 11, 2026
f721eb7
List View Features
Jan 12, 2026
007830e
feat: Add responsive session manager with mobile backdrop overlay
Jan 12, 2026
e239424
feat: Implement responsive mobile header layout with menu consolidation
Jan 12, 2026
c7def00
feat: Handle backlog feature editing on row click in board view
Jan 12, 2026
df7a0f8
feat: Make input controls and settings responsive for mobile devices
Jan 12, 2026
d122226
feat: Improve Claude CLI usage detection, mobile usage view, and add …
Jan 12, 2026
74c793b
Add branch switch to mobile worktree panel
Jan 12, 2026
1b9d194
feat: Improve mobile scrolling experience in autocomplete and dropdow…
Jan 12, 2026
8b19266
feat: Add secondary inline actions for waiting_approval status
Jan 12, 2026
5e4f5f8
feat(worktree): add AI commit message generation feature
Shironex Jan 12, 2026
0261ec2
feat(prompts): implement customizable commit message prompts
Shironex Jan 12, 2026
19c12b7
refactor(settings): remove deprecated notification settings from Glob…
Shironex Jan 12, 2026
cca4638
fix: adjust pr commnets
Shironex Jan 12, 2026
47c188d
fix: adress pr comments
Shironex Jan 12, 2026
1f270ed
refactor(list-row): remove priority display logic and related components
Shironex Jan 12, 2026
029c5ca
fix: adress pr comments
Shironex Jan 12, 2026
ca3b013
Merge v0.11.0rc into feat/mobile-improvements-contributor
Shironex Jan 13, 2026
6cb2af8
test: update claude-usage-service tests for improved error handling a…
Shironex Jan 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService));
app.use('/api/features', createFeaturesRoutes(featureLoader));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
app.use('/api/git', createGitRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes());
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/routes/worktree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createDeleteHandler } from './routes/delete.js';
import { createCreatePRHandler } from './routes/create-pr.js';
import { createPRInfoHandler } from './routes/pr-info.js';
import { createCommitHandler } from './routes/commit.js';
import { createGenerateCommitMessageHandler } from './routes/generate-commit-message.js';
import { createPushHandler } from './routes/push.js';
import { createPullHandler } from './routes/pull.js';
import { createCheckoutBranchHandler } from './routes/checkout-branch.js';
Expand All @@ -39,8 +40,12 @@ import {
createDeleteInitScriptHandler,
createRunInitScriptHandler,
} from './routes/init-script.js';
import type { SettingsService } from '../../services/settings-service.js';

export function createWorktreeRoutes(events: EventEmitter): Router {
export function createWorktreeRoutes(
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router();

router.post('/info', validatePathParams('projectPath'), createInfoHandler());
Expand All @@ -64,6 +69,12 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
requireGitRepoOnly,
createCommitHandler()
);
router.post(
'/generate-commit-message',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createGenerateCommitMessageHandler(settingsService)
);
router.post(
'/push',
validatePathParams('worktreePath'),
Expand Down
275 changes: 275 additions & 0 deletions apps/server/src/routes/worktree/routes/generate-commit-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
/**
* POST /worktree/generate-commit-message endpoint - Generate an AI commit message from git diff
*
* Uses the configured model (via phaseModels.commitMessageModel) to generate a concise,
* conventional commit message from git changes. Defaults to Claude Haiku for speed.
*/

import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { existsSync } from 'fs';
import { join } from 'path';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { mergeCommitMessagePrompts } from '@automaker/prompts';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.js';

const logger = createLogger('GenerateCommitMessage');
const execAsync = promisify(exec);

/** Timeout for AI provider calls in milliseconds (30 seconds) */
const AI_TIMEOUT_MS = 30_000;

/**
* Wraps an async generator with a timeout.
* If the generator takes longer than the timeout, it throws an error.
*/
async function* withTimeout<T>(
generator: AsyncIterable<T>,
timeoutMs: number
): AsyncGenerator<T, void, unknown> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs);
});

const iterator = generator[Symbol.asyncIterator]();
let done = false;

while (!done) {
const result = await Promise.race([iterator.next(), timeoutPromise]);
if (result.done) {
done = true;
} else {
yield result.value;
}
}
}

/**
* Get the effective system prompt for commit message generation.
* Uses custom prompt from settings if enabled, otherwise falls back to default.
*/
async function getSystemPrompt(settingsService?: SettingsService): Promise<string> {
const settings = await settingsService?.getGlobalSettings();
const prompts = mergeCommitMessagePrompts(settings?.promptCustomization?.commitMessage);
return prompts.systemPrompt;
}

interface GenerateCommitMessageRequestBody {
worktreePath: string;
}

interface GenerateCommitMessageSuccessResponse {
success: true;
message: string;
}

interface GenerateCommitMessageErrorResponse {
success: false;
error: string;
}

async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';

for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}

return responseText;
}

export function createGenerateCommitMessageHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as GenerateCommitMessageRequestBody;

if (!worktreePath || typeof worktreePath !== 'string') {
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: 'worktreePath is required and must be a string',
};
res.status(400).json(response);
return;
}

// Validate that the directory exists
if (!existsSync(worktreePath)) {
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: 'worktreePath does not exist',
};
res.status(400).json(response);
return;
}

// Validate that it's a git repository (check for .git folder or file for worktrees)
const gitPath = join(worktreePath, '.git');
if (!existsSync(gitPath)) {
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: 'worktreePath is not a git repository',
};
res.status(400).json(response);
return;
}

logger.info(`Generating commit message for worktree: ${worktreePath}`);

// Get git diff of staged and unstaged changes
let diff = '';
try {
// First try to get staged changes
const { stdout: stagedDiff } = await execAsync('git diff --cached', {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
});

// If no staged changes, get unstaged changes
if (!stagedDiff.trim()) {
const { stdout: unstagedDiff } = await execAsync('git diff', {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
});
diff = unstagedDiff;
} else {
diff = stagedDiff;
}
} catch (error) {
logger.error('Failed to get git diff:', error);
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: 'Failed to get git changes',
};
res.status(500).json(response);
return;
}

if (!diff.trim()) {
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: 'No changes to commit',
};
res.status(400).json(response);
return;
}

// Truncate diff if too long (keep first 10000 characters to avoid token limits)
const truncatedDiff =
diff.length > 10000 ? diff.substring(0, 10000) + '\n\n[... diff truncated ...]' : diff;

const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;

// Get model from phase settings
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.commitMessageModel || DEFAULT_PHASE_MODELS.commitMessageModel;
const { model } = resolvePhaseModel(phaseModelEntry);

logger.info(`Using model for commit message: ${model}`);

// Get the effective system prompt (custom or default)
const systemPrompt = await getSystemPrompt(settingsService);

let message: string;

// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info(`Using Cursor provider for model: ${model}`);

const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);

const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;

let responseText = '';
const cursorStream = provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd: worktreePath,
maxTurns: 1,
allowedTools: [],
readOnly: true,
});

// Wrap with timeout to prevent indefinite hangs
for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
}
}

message = responseText.trim();
} else {
// Use Claude SDK for Claude models
const stream = query({
prompt: userPrompt,
options: {
model,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'default',
},
});

// Wrap with timeout to prevent indefinite hangs
message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS));
}

if (!message || message.trim().length === 0) {
logger.warn('Received empty response from model');
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: 'Failed to generate commit message - empty response',
};
res.status(500).json(response);
return;
}

logger.info(`Generated commit message: ${message.trim().substring(0, 100)}...`);

const response: GenerateCommitMessageSuccessResponse = {
success: true,
message: message.trim(),
};
res.json(response);
} catch (error) {
logError(error, 'Generate commit message failed');
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: getErrorMessage(error),
};
res.status(500).json(response);
}
};
}
Loading