-
Notifications
You must be signed in to change notification settings - Fork 490
feat: Mobile responsiveness improvements from community contributor #449
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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
acce06b
Fix sidebar lables not showign up
d2c7a9e
Fix model selector on mobile
e56db23
feat: Add AI-generated commit messages
f721eb7
List View Features
007830e
feat: Add responsive session manager with mobile backdrop overlay
e239424
feat: Implement responsive mobile header layout with menu consolidation
c7def00
feat: Handle backlog feature editing on row click in board view
df7a0f8
feat: Make input controls and settings responsive for mobile devices
d122226
feat: Improve Claude CLI usage detection, mobile usage view, and add …
74c793b
Add branch switch to mobile worktree panel
1b9d194
feat: Improve mobile scrolling experience in autocomplete and dropdow…
8b19266
feat: Add secondary inline actions for waiting_approval status
5e4f5f8
feat(worktree): add AI commit message generation feature
Shironex 0261ec2
feat(prompts): implement customizable commit message prompts
Shironex 19c12b7
refactor(settings): remove deprecated notification settings from Glob…
Shironex cca4638
fix: adjust pr commnets
Shironex 47c188d
fix: adress pr comments
Shironex 1f270ed
refactor(list-row): remove priority display logic and related components
Shironex 029c5ca
fix: adress pr comments
Shironex ca3b013
Merge v0.11.0rc into feat/mobile-improvements-contributor
Shironex 6cb2af8
test: update claude-usage-service tests for improved error handling a…
Shironex File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
275 changes: 275 additions & 0 deletions
275
apps/server/src/routes/worktree/routes/generate-commit-message.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
Shironex marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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); | ||
| } | ||
| }; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.