mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
Merge pull request #449 from AutoMaker-Org/feat/mobile-improvements-contributor
feat: Mobile responsiveness improvements from community contributor
This commit is contained in:
@@ -217,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService));
|
|||||||
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
||||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
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/git', createGitRoutes());
|
||||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||||
app.use('/api/models', createModelsRoutes());
|
app.use('/api/models', createModelsRoutes());
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { createDeleteHandler } from './routes/delete.js';
|
|||||||
import { createCreatePRHandler } from './routes/create-pr.js';
|
import { createCreatePRHandler } from './routes/create-pr.js';
|
||||||
import { createPRInfoHandler } from './routes/pr-info.js';
|
import { createPRInfoHandler } from './routes/pr-info.js';
|
||||||
import { createCommitHandler } from './routes/commit.js';
|
import { createCommitHandler } from './routes/commit.js';
|
||||||
|
import { createGenerateCommitMessageHandler } from './routes/generate-commit-message.js';
|
||||||
import { createPushHandler } from './routes/push.js';
|
import { createPushHandler } from './routes/push.js';
|
||||||
import { createPullHandler } from './routes/pull.js';
|
import { createPullHandler } from './routes/pull.js';
|
||||||
import { createCheckoutBranchHandler } from './routes/checkout-branch.js';
|
import { createCheckoutBranchHandler } from './routes/checkout-branch.js';
|
||||||
@@ -39,8 +40,12 @@ import {
|
|||||||
createDeleteInitScriptHandler,
|
createDeleteInitScriptHandler,
|
||||||
createRunInitScriptHandler,
|
createRunInitScriptHandler,
|
||||||
} from './routes/init-script.js';
|
} 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();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
||||||
@@ -64,6 +69,12 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
|
|||||||
requireGitRepoOnly,
|
requireGitRepoOnly,
|
||||||
createCommitHandler()
|
createCommitHandler()
|
||||||
);
|
);
|
||||||
|
router.post(
|
||||||
|
'/generate-commit-message',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createGenerateCommitMessageHandler(settingsService)
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/push',
|
'/push',
|
||||||
validatePathParams('worktreePath'),
|
validatePathParams('worktreePath'),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -161,11 +161,15 @@ export class ClaudeUsageService {
|
|||||||
|
|
||||||
const workingDirectory = this.isWindows
|
const workingDirectory = this.isWindows
|
||||||
? process.env.USERPROFILE || os.homedir() || 'C:\\'
|
? process.env.USERPROFILE || os.homedir() || 'C:\\'
|
||||||
: process.env.HOME || os.homedir() || '/tmp';
|
: os.tmpdir();
|
||||||
|
|
||||||
// Use platform-appropriate shell and command
|
// Use platform-appropriate shell and command
|
||||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
// Use --add-dir to whitelist the current directory and bypass the trust prompt
|
||||||
|
// We don't pass /usage here, we'll type it into the REPL
|
||||||
|
const args = this.isWindows
|
||||||
|
? ['/c', 'claude', '--add-dir', workingDirectory]
|
||||||
|
: ['-c', `claude --add-dir "${workingDirectory}"`];
|
||||||
|
|
||||||
let ptyProcess: any = null;
|
let ptyProcess: any = null;
|
||||||
|
|
||||||
@@ -181,8 +185,6 @@ export class ClaudeUsageService {
|
|||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
});
|
});
|
||||||
} catch (spawnError) {
|
} catch (spawnError) {
|
||||||
// pty.spawn() can throw synchronously if the native module fails to load
|
|
||||||
// or if PTY is not available in the current environment (e.g., containers without /dev/pts)
|
|
||||||
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||||
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||||
|
|
||||||
@@ -205,16 +207,52 @@ export class ClaudeUsageService {
|
|||||||
if (output.includes('Current session')) {
|
if (output.includes('Current session')) {
|
||||||
resolve(output);
|
resolve(output);
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('Command timed out'));
|
reject(
|
||||||
|
new Error(
|
||||||
|
'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.'
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, this.timeout);
|
}, 45000); // 45 second timeout
|
||||||
|
|
||||||
|
let hasSentCommand = false;
|
||||||
|
let hasApprovedTrust = false;
|
||||||
|
|
||||||
ptyProcess.onData((data: string) => {
|
ptyProcess.onData((data: string) => {
|
||||||
output += data;
|
output += data;
|
||||||
|
|
||||||
// Check if we've seen the usage data (look for "Current session")
|
// Strip ANSI codes for easier matching
|
||||||
if (!hasSeenUsageData && output.includes('Current session')) {
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
||||||
|
|
||||||
|
// Check for specific authentication/permission errors
|
||||||
|
if (
|
||||||
|
cleanOutput.includes('OAuth token does not meet scope requirement') ||
|
||||||
|
cleanOutput.includes('permission_error') ||
|
||||||
|
cleanOutput.includes('token_expired') ||
|
||||||
|
cleanOutput.includes('authentication_error')
|
||||||
|
) {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
if (ptyProcess && !ptyProcess.killed) {
|
||||||
|
ptyProcess.kill();
|
||||||
|
}
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've seen the usage data (look for "Current session" or the TUI Usage header)
|
||||||
|
if (
|
||||||
|
!hasSeenUsageData &&
|
||||||
|
(cleanOutput.includes('Current session') ||
|
||||||
|
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')))
|
||||||
|
) {
|
||||||
hasSeenUsageData = true;
|
hasSeenUsageData = true;
|
||||||
// Wait for full output, then send escape to exit
|
// Wait for full output, then send escape to exit
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -228,16 +266,54 @@ export class ClaudeUsageService {
|
|||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Trust Dialog: "Do you want to work in this folder?"
|
||||||
|
// Since we are running in os.tmpdir(), it is safe to approve.
|
||||||
|
if (!hasApprovedTrust && cleanOutput.includes('Do you want to work in this folder?')) {
|
||||||
|
hasApprovedTrust = true;
|
||||||
|
// Wait a tiny bit to ensure prompt is ready, then send Enter
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
|
ptyProcess.write('\r');
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect REPL prompt and send /usage command
|
||||||
|
if (
|
||||||
|
!hasSentCommand &&
|
||||||
|
(cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts'))
|
||||||
|
) {
|
||||||
|
hasSentCommand = true;
|
||||||
|
// Wait for REPL to fully settle
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
|
// Send command with carriage return
|
||||||
|
ptyProcess.write('/usage\r');
|
||||||
|
|
||||||
|
// Send another enter after 1 second to confirm selection if autocomplete menu appeared
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
|
ptyProcess.write('\r');
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
||||||
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
if (
|
||||||
|
!hasSeenUsageData &&
|
||||||
|
cleanOutput.includes('Esc to cancel') &&
|
||||||
|
!cleanOutput.includes('Do you want to work in this folder?')
|
||||||
|
) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.write('\x1b'); // Send escape key
|
ptyProcess.write('\x1b'); // Send escape key
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,8 +322,11 @@ export class ClaudeUsageService {
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
|
||||||
// Check for authentication errors in output
|
if (
|
||||||
if (output.includes('token_expired') || output.includes('authentication_error')) {
|
output.includes('token_expired') ||
|
||||||
|
output.includes('authentication_error') ||
|
||||||
|
output.includes('permission_error')
|
||||||
|
) {
|
||||||
reject(new Error("Authentication required - please run 'claude login'"));
|
reject(new Error("Authentication required - please run 'claude login'"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -551,7 +551,7 @@ Resets in 2h
|
|||||||
expect(result.sessionPercentage).toBe(35);
|
expect(result.sessionPercentage).toBe(35);
|
||||||
expect(pty.spawn).toHaveBeenCalledWith(
|
expect(pty.spawn).toHaveBeenCalledWith(
|
||||||
'cmd.exe',
|
'cmd.exe',
|
||||||
['/c', 'claude', '/usage'],
|
['/c', 'claude', '--add-dir', 'C:\\Users\\testuser'],
|
||||||
expect.any(Object)
|
expect.any(Object)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -582,8 +582,8 @@ Resets in 2h
|
|||||||
// Simulate seeing usage data
|
// Simulate seeing usage data
|
||||||
dataCallback!(mockOutput);
|
dataCallback!(mockOutput);
|
||||||
|
|
||||||
// Advance time to trigger escape key sending
|
// Advance time to trigger escape key sending (impl uses 3000ms delay)
|
||||||
vi.advanceTimersByTime(2100);
|
vi.advanceTimersByTime(3100);
|
||||||
|
|
||||||
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
|
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
|
||||||
|
|
||||||
@@ -614,9 +614,10 @@ Resets in 2h
|
|||||||
const promise = windowsService.fetchUsageData();
|
const promise = windowsService.fetchUsageData();
|
||||||
|
|
||||||
dataCallback!('authentication_error');
|
dataCallback!('authentication_error');
|
||||||
exitCallback!({ exitCode: 1 });
|
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow('Authentication required');
|
await expect(promise).rejects.toThrow(
|
||||||
|
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle timeout with no data on Windows', async () => {
|
it('should handle timeout with no data on Windows', async () => {
|
||||||
@@ -628,14 +629,18 @@ Resets in 2h
|
|||||||
onExit: vi.fn(),
|
onExit: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
kill: vi.fn(),
|
kill: vi.fn(),
|
||||||
|
killed: false,
|
||||||
};
|
};
|
||||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||||
|
|
||||||
const promise = windowsService.fetchUsageData();
|
const promise = windowsService.fetchUsageData();
|
||||||
|
|
||||||
vi.advanceTimersByTime(31000);
|
// Advance time past timeout (45 seconds)
|
||||||
|
vi.advanceTimersByTime(46000);
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow('Command timed out');
|
await expect(promise).rejects.toThrow(
|
||||||
|
'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.'
|
||||||
|
);
|
||||||
expect(mockPty.kill).toHaveBeenCalled();
|
expect(mockPty.kill).toHaveBeenCalled();
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -654,6 +659,7 @@ Resets in 2h
|
|||||||
onExit: vi.fn(),
|
onExit: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
kill: vi.fn(),
|
kill: vi.fn(),
|
||||||
|
killed: false,
|
||||||
};
|
};
|
||||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||||
|
|
||||||
@@ -662,8 +668,8 @@ Resets in 2h
|
|||||||
// Simulate receiving usage data
|
// Simulate receiving usage data
|
||||||
dataCallback!('Current session\n65% left\nResets in 2h');
|
dataCallback!('Current session\n65% left\nResets in 2h');
|
||||||
|
|
||||||
// Advance time past timeout (30 seconds)
|
// Advance time past timeout (45 seconds)
|
||||||
vi.advanceTimersByTime(31000);
|
vi.advanceTimersByTime(46000);
|
||||||
|
|
||||||
// Should resolve with data instead of rejecting
|
// Should resolve with data instead of rejecting
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
@@ -686,6 +692,7 @@ Resets in 2h
|
|||||||
onExit: vi.fn(),
|
onExit: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
kill: vi.fn(),
|
kill: vi.fn(),
|
||||||
|
killed: false,
|
||||||
};
|
};
|
||||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||||
|
|
||||||
@@ -694,8 +701,8 @@ Resets in 2h
|
|||||||
// Simulate seeing usage data
|
// Simulate seeing usage data
|
||||||
dataCallback!('Current session\n65% left');
|
dataCallback!('Current session\n65% left');
|
||||||
|
|
||||||
// Advance 2s to trigger ESC
|
// Advance 3s to trigger ESC (impl uses 3000ms delay)
|
||||||
vi.advanceTimersByTime(2100);
|
vi.advanceTimersByTime(3100);
|
||||||
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
|
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
|
||||||
|
|
||||||
// Advance another 2s to trigger SIGTERM fallback
|
// Advance another 2s to trigger SIGTERM fallback
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { router } from './utils/router';
|
|||||||
import { SplashScreen } from './components/splash-screen';
|
import { SplashScreen } from './components/splash-screen';
|
||||||
import { useSettingsSync } from './hooks/use-settings-sync';
|
import { useSettingsSync } from './hooks/use-settings-sync';
|
||||||
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
||||||
|
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
|
||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
import './styles/theme-imports';
|
import './styles/theme-imports';
|
||||||
|
|
||||||
@@ -24,8 +25,11 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
const clearPerfEntries = () => {
|
const clearPerfEntries = () => {
|
||||||
performance.clearMarks();
|
// Check if window.performance is available before calling its methods
|
||||||
performance.clearMeasures();
|
if (window.performance) {
|
||||||
|
window.performance.clearMarks();
|
||||||
|
window.performance.clearMeasures();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const interval = setInterval(clearPerfEntries, 5000);
|
const interval = setInterval(clearPerfEntries, 5000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
@@ -45,6 +49,9 @@ export default function App() {
|
|||||||
// Initialize Cursor CLI status at startup
|
// Initialize Cursor CLI status at startup
|
||||||
useCursorStatusInit();
|
useCursorStatusInit();
|
||||||
|
|
||||||
|
// Initialize Provider auth status at startup (for Claude/Codex usage display)
|
||||||
|
useProviderAuthInit();
|
||||||
|
|
||||||
const handleSplashComplete = useCallback(() => {
|
const handleSplashComplete = useCallback(() => {
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
setShowSplash(false);
|
setShowSplash(false);
|
||||||
|
|||||||
@@ -253,26 +253,25 @@ export function Sidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile overlay backdrop */}
|
{/* Mobile backdrop overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
aria-hidden="true"
|
data-testid="sidebar-backdrop"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-shrink-0 flex flex-col z-50 relative',
|
'flex-shrink-0 flex flex-col z-30',
|
||||||
// Glass morphism background with gradient
|
// Glass morphism background with gradient
|
||||||
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
||||||
// Premium border with subtle glow
|
// Premium border with subtle glow
|
||||||
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
||||||
// Smooth width transition
|
// Smooth width transition
|
||||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||||
// Mobile: hidden when closed, full width overlay when open
|
// Mobile: overlay when open, collapsed when closed
|
||||||
// Desktop: always visible, toggle between narrow and wide
|
sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16'
|
||||||
sidebarOpen ? 'fixed lg:relative left-0 top-0 h-full w-72' : 'hidden lg:flex w-16'
|
|
||||||
)}
|
)}
|
||||||
data-testid="sidebar"
|
data-testid="sidebar"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ export function CollapseToggleButton({
|
|||||||
<button
|
<button
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
// Show on desktop always, show on mobile only when sidebar is open
|
'flex absolute top-[68px] -right-3 z-9999',
|
||||||
sidebarOpen ? 'flex' : 'hidden lg:flex',
|
|
||||||
'absolute top-[68px] -right-3 z-9999',
|
|
||||||
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
||||||
// Glass morphism button
|
// Glass morphism button
|
||||||
'bg-card/95 backdrop-blur-sm border border-border/80',
|
'bg-card/95 backdrop-blur-sm border border-border/80',
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function ProjectActions({
|
|||||||
data-testid="new-project-button"
|
data-testid="new-project-button"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:rotate-90 group-hover:text-brand-500" />
|
<Plus className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:rotate-90 group-hover:text-brand-500" />
|
||||||
<span className="ml-2 text-sm font-medium hidden lg:block whitespace-nowrap">New</span>
|
<span className="ml-2 text-sm font-medium block whitespace-nowrap">New</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenFolder}
|
onClick={handleOpenFolder}
|
||||||
@@ -59,7 +59,7 @@ export function ProjectActions({
|
|||||||
data-testid="open-project-button"
|
data-testid="open-project-button"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:scale-110" />
|
<FolderOpen className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:scale-110" />
|
||||||
<span className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted/80 text-muted-foreground ml-2">
|
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted/80 text-muted-foreground ml-2">
|
||||||
{formatShortcut(shortcuts.openProject, true)}
|
{formatShortcut(shortcuts.openProject, true)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export function SidebarFooter({
|
|||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||||
isActiveRoute('settings')
|
isActiveRoute('settings')
|
||||||
? 'bg-brand-500/20 text-brand-400'
|
? 'bg-brand-500/20 text-brand-400'
|
||||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function SidebarNavigation({
|
|||||||
// Placeholder when no project is selected (only in expanded state)
|
// Placeholder when no project is selected (only in expanded state)
|
||||||
<div className="flex items-center justify-center h-full px-4">
|
<div className="flex items-center justify-center h-full px-4">
|
||||||
<p className="text-muted-foreground text-sm text-center">
|
<p className="text-muted-foreground text-sm text-center">
|
||||||
<span className="hidden lg:block">Select or create a project above</span>
|
<span className="block">Select or create a project above</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : currentProject ? (
|
) : currentProject ? (
|
||||||
@@ -137,7 +137,7 @@ export function SidebarNavigation({
|
|||||||
{item.shortcut && sidebarOpen && !item.count && (
|
{item.shortcut && sidebarOpen && !item.count && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-brand-500/20 text-brand-400'
|
? 'bg-brand-500/20 text-brand-400'
|
||||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export function Autocomplete({
|
|||||||
width: Math.max(triggerWidth, 200),
|
width: Math.max(triggerWidth, 200),
|
||||||
}}
|
}}
|
||||||
data-testid={testId ? `${testId}-list` : undefined}
|
data-testid={testId ? `${testId}-list` : undefined}
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
onTouchMove={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
|
|||||||
@@ -78,7 +78,14 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
|
|||||||
return (
|
return (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
data-slot="command-list"
|
data-slot="command-list"
|
||||||
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
|
className={cn(
|
||||||
|
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
|
||||||
|
// Mobile touch scrolling support
|
||||||
|
'touch-pan-y overscroll-contain',
|
||||||
|
// iOS Safari momentum scrolling
|
||||||
|
'[&]:[-webkit-overflow-scrolling:touch]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,18 +72,17 @@ export function UsagePopover() {
|
|||||||
const [codexError, setCodexError] = useState<UsageError | null>(null);
|
const [codexError, setCodexError] = useState<UsageError | null>(null);
|
||||||
|
|
||||||
// Check authentication status
|
// Check authentication status
|
||||||
const isClaudeCliVerified =
|
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
||||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
|
||||||
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
||||||
|
|
||||||
// Determine which tab to show by default
|
// Determine which tab to show by default
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isClaudeCliVerified) {
|
if (isClaudeAuthenticated) {
|
||||||
setActiveTab('claude');
|
setActiveTab('claude');
|
||||||
} else if (isCodexAuthenticated) {
|
} else if (isCodexAuthenticated) {
|
||||||
setActiveTab('codex');
|
setActiveTab('codex');
|
||||||
}
|
}
|
||||||
}, [isClaudeCliVerified, isCodexAuthenticated]);
|
}, [isClaudeAuthenticated, isCodexAuthenticated]);
|
||||||
|
|
||||||
// Check if data is stale (older than 2 minutes)
|
// Check if data is stale (older than 2 minutes)
|
||||||
const isClaudeStale = useMemo(() => {
|
const isClaudeStale = useMemo(() => {
|
||||||
@@ -174,10 +173,10 @@ export function UsagePopover() {
|
|||||||
|
|
||||||
// Auto-fetch on mount if data is stale
|
// Auto-fetch on mount if data is stale
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isClaudeStale && isClaudeCliVerified) {
|
if (isClaudeStale && isClaudeAuthenticated) {
|
||||||
fetchClaudeUsage(true);
|
fetchClaudeUsage(true);
|
||||||
}
|
}
|
||||||
}, [isClaudeStale, isClaudeCliVerified, fetchClaudeUsage]);
|
}, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCodexStale && isCodexAuthenticated) {
|
if (isCodexStale && isCodexAuthenticated) {
|
||||||
@@ -190,7 +189,7 @@ export function UsagePopover() {
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
// Fetch based on active tab
|
// Fetch based on active tab
|
||||||
if (activeTab === 'claude' && isClaudeCliVerified) {
|
if (activeTab === 'claude' && isClaudeAuthenticated) {
|
||||||
if (!claudeUsage || isClaudeStale) {
|
if (!claudeUsage || isClaudeStale) {
|
||||||
fetchClaudeUsage();
|
fetchClaudeUsage();
|
||||||
}
|
}
|
||||||
@@ -214,7 +213,7 @@ export function UsagePopover() {
|
|||||||
activeTab,
|
activeTab,
|
||||||
claudeUsage,
|
claudeUsage,
|
||||||
isClaudeStale,
|
isClaudeStale,
|
||||||
isClaudeCliVerified,
|
isClaudeAuthenticated,
|
||||||
codexUsage,
|
codexUsage,
|
||||||
isCodexStale,
|
isCodexStale,
|
||||||
isCodexAuthenticated,
|
isCodexAuthenticated,
|
||||||
@@ -349,7 +348,7 @@ export function UsagePopover() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Determine which tabs to show
|
// Determine which tabs to show
|
||||||
const showClaudeTab = isClaudeCliVerified;
|
const showClaudeTab = isClaudeAuthenticated;
|
||||||
const showCodexTab = isCodexAuthenticated;
|
const showCodexTab = isCodexAuthenticated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,11 +16,32 @@ import {
|
|||||||
import { NoProjectState, AgentHeader, ChatArea } from './agent-view/components';
|
import { NoProjectState, AgentHeader, ChatArea } from './agent-view/components';
|
||||||
import { AgentInputArea } from './agent-view/input-area';
|
import { AgentInputArea } from './agent-view/input-area';
|
||||||
|
|
||||||
|
/** Tailwind lg breakpoint in pixels */
|
||||||
|
const LG_BREAKPOINT = 1024;
|
||||||
|
|
||||||
export function AgentView() {
|
export function AgentView() {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
||||||
|
// Initialize session manager state - starts as true to match SSR
|
||||||
|
// Then updates on mount based on actual screen size to prevent hydration mismatch
|
||||||
const [showSessionManager, setShowSessionManager] = useState(true);
|
const [showSessionManager, setShowSessionManager] = useState(true);
|
||||||
|
|
||||||
|
// Update session manager visibility based on screen size after mount and on resize
|
||||||
|
useEffect(() => {
|
||||||
|
const updateVisibility = () => {
|
||||||
|
const isDesktop = window.innerWidth >= LG_BREAKPOINT;
|
||||||
|
setShowSessionManager(isDesktop);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
updateVisibility();
|
||||||
|
|
||||||
|
// Listen for resize events
|
||||||
|
window.addEventListener('resize', updateVisibility);
|
||||||
|
return () => window.removeEventListener('resize', updateVisibility);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
|
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
|
||||||
|
|
||||||
// Input ref for auto-focus
|
// Input ref for auto-focus
|
||||||
@@ -119,6 +140,13 @@ export function AgentView() {
|
|||||||
}
|
}
|
||||||
}, [currentSessionId]);
|
}, [currentSessionId]);
|
||||||
|
|
||||||
|
// Auto-close session manager on mobile when a session is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentSessionId && typeof window !== 'undefined' && window.innerWidth < 1024) {
|
||||||
|
setShowSessionManager(false);
|
||||||
|
}
|
||||||
|
}, [currentSessionId]);
|
||||||
|
|
||||||
// Show welcome message if no messages yet
|
// Show welcome message if no messages yet
|
||||||
const displayMessages =
|
const displayMessages =
|
||||||
messages.length === 0
|
messages.length === 0
|
||||||
@@ -139,9 +167,18 @@ export function AgentView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
|
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
|
||||||
|
{/* Mobile backdrop overlay for Session Manager */}
|
||||||
|
{showSessionManager && currentProject && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||||
|
onClick={() => setShowSessionManager(false)}
|
||||||
|
data-testid="session-manager-backdrop"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Session Manager Sidebar */}
|
{/* Session Manager Sidebar */}
|
||||||
{showSessionManager && currentProject && (
|
{showSessionManager && currentProject && (
|
||||||
<div className="w-80 border-r border-border shrink-0 bg-card/50">
|
<div className="fixed inset-y-0 left-0 w-72 z-30 lg:relative lg:w-80 lg:z-auto border-r border-border shrink-0 bg-card">
|
||||||
<SessionManager
|
<SessionManager
|
||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function InputControls({
|
|||||||
{/* Text Input and Controls */}
|
{/* Text Input and Controls */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex gap-2 transition-all duration-200 rounded-xl p-1',
|
'flex flex-col gap-2 transition-all duration-200 rounded-xl p-1',
|
||||||
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
|
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
|
||||||
)}
|
)}
|
||||||
onDragEnter={onDragEnter}
|
onDragEnter={onDragEnter}
|
||||||
@@ -87,7 +87,8 @@ export function InputControls({
|
|||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
>
|
>
|
||||||
<div className="flex-1 relative">
|
{/* Textarea - full width on mobile */}
|
||||||
|
<div className="relative w-full">
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={
|
placeholder={
|
||||||
@@ -105,14 +106,14 @@ export function InputControls({
|
|||||||
data-testid="agent-input"
|
data-testid="agent-input"
|
||||||
rows={1}
|
rows={1}
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
|
'min-h-11 w-full bg-background border-border rounded-xl pl-4 pr-4 sm:pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
|
||||||
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
|
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
|
||||||
hasFiles && 'border-primary/30',
|
hasFiles && 'border-primary/30',
|
||||||
isDragOver && 'border-primary bg-primary/5'
|
isDragOver && 'border-primary bg-primary/5'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{hasFiles && !isDragOver && (
|
{hasFiles && !isDragOver && (
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
|
<div className="hidden sm:block absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
|
||||||
files attached
|
files attached
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -124,58 +125,64 @@ export function InputControls({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Selector */}
|
{/* Controls row - responsive layout */}
|
||||||
<AgentModelSelector
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
value={modelSelection}
|
{/* Model Selector */}
|
||||||
onChange={onModelSelect}
|
<AgentModelSelector
|
||||||
disabled={!isConnected}
|
value={modelSelection}
|
||||||
/>
|
onChange={onModelSelect}
|
||||||
|
|
||||||
{/* File Attachment Button */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={onToggleImageDropZone}
|
|
||||||
disabled={!isConnected}
|
|
||||||
className={cn(
|
|
||||||
'h-11 w-11 rounded-xl border-border',
|
|
||||||
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
|
|
||||||
hasFiles && 'border-primary/30 text-primary'
|
|
||||||
)}
|
|
||||||
title="Attach files (images, .txt, .md)"
|
|
||||||
>
|
|
||||||
<Paperclip className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Stop Button (only when processing) */}
|
|
||||||
{isProcessing && (
|
|
||||||
<Button
|
|
||||||
onClick={onStop}
|
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
className="h-11 px-4 rounded-xl"
|
/>
|
||||||
variant="destructive"
|
|
||||||
data-testid="stop-agent"
|
|
||||||
title="Stop generation"
|
|
||||||
>
|
|
||||||
<Square className="w-4 h-4 fill-current" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Send / Queue Button */}
|
{/* File Attachment Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={onSend}
|
variant="outline"
|
||||||
disabled={!canSend}
|
size="icon"
|
||||||
className="h-11 px-4 rounded-xl"
|
onClick={onToggleImageDropZone}
|
||||||
variant={isProcessing ? 'outline' : 'default'}
|
disabled={!isConnected}
|
||||||
data-testid="send-message"
|
className={cn(
|
||||||
title={isProcessing ? 'Add to queue' : 'Send message'}
|
'h-11 w-11 rounded-xl border-border shrink-0',
|
||||||
>
|
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
|
||||||
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
|
hasFiles && 'border-primary/30 text-primary'
|
||||||
</Button>
|
)}
|
||||||
|
title="Attach files (images, .txt, .md)"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Spacer to push action buttons to the right */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Stop Button (only when processing) */}
|
||||||
|
{isProcessing && (
|
||||||
|
<Button
|
||||||
|
onClick={onStop}
|
||||||
|
disabled={!isConnected}
|
||||||
|
className="h-11 px-4 rounded-xl shrink-0"
|
||||||
|
variant="destructive"
|
||||||
|
data-testid="stop-agent"
|
||||||
|
title="Stop generation"
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4 fill-current" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Send / Queue Button */}
|
||||||
|
<Button
|
||||||
|
onClick={onSend}
|
||||||
|
disabled={!canSend}
|
||||||
|
className="h-11 px-4 rounded-xl shrink-0"
|
||||||
|
variant={isProcessing ? 'outline' : 'default'}
|
||||||
|
data-testid="send-message"
|
||||||
|
title={isProcessing ? 'Add to queue' : 'Send message'}
|
||||||
|
>
|
||||||
|
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
<p className="text-[11px] text-muted-foreground mt-2 text-center hidden sm:block">
|
||||||
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
|
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
|
||||||
send,{' '}
|
send,{' '}
|
||||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Shift+Enter</kbd>{' '}
|
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Shift+Enter</kbd>{' '}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
|
|||||||
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
|
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
|
||||||
import { WorktreePanel } from './board-view/worktree-panel';
|
import { WorktreePanel } from './board-view/worktree-panel';
|
||||||
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
|
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
|
||||||
import { COLUMNS } from './board-view/constants';
|
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
|
||||||
import {
|
import {
|
||||||
useBoardFeatures,
|
useBoardFeatures,
|
||||||
useBoardDragDrop,
|
useBoardDragDrop,
|
||||||
@@ -72,8 +72,9 @@ import {
|
|||||||
useBoardPersistence,
|
useBoardPersistence,
|
||||||
useFollowUpState,
|
useFollowUpState,
|
||||||
useSelectionMode,
|
useSelectionMode,
|
||||||
|
useListViewState,
|
||||||
} from './board-view/hooks';
|
} from './board-view/hooks';
|
||||||
import { SelectionActionBar } from './board-view/components';
|
import { SelectionActionBar, ListView } from './board-view/components';
|
||||||
import { MassEditDialog } from './board-view/dialogs';
|
import { MassEditDialog } from './board-view/dialogs';
|
||||||
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
||||||
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
||||||
@@ -194,6 +195,9 @@ export function BoardView() {
|
|||||||
} = useSelectionMode();
|
} = useSelectionMode();
|
||||||
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
|
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
|
||||||
|
|
||||||
|
// View mode state (kanban vs list)
|
||||||
|
const { viewMode, setViewMode, isListView, sortConfig, setSortColumn } = useListViewState();
|
||||||
|
|
||||||
// Search filter for Kanban cards
|
// Search filter for Kanban cards
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
// Plan approval loading state
|
// Plan approval loading state
|
||||||
@@ -1119,6 +1123,19 @@ export function BoardView() {
|
|||||||
projectPath: currentProject?.path || null,
|
projectPath: currentProject?.path || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build columnFeaturesMap for ListView
|
||||||
|
const pipelineConfig = currentProject?.path
|
||||||
|
? pipelineConfigByProject[currentProject.path] || null
|
||||||
|
: null;
|
||||||
|
const columnFeaturesMap = useMemo(() => {
|
||||||
|
const columns = getColumnsWithPipeline(pipelineConfig);
|
||||||
|
const map: Record<string, typeof hookFeatures> = {};
|
||||||
|
for (const column of columns) {
|
||||||
|
map[column.id] = getColumnFeatures(column.id as any);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [pipelineConfig, getColumnFeatures]);
|
||||||
|
|
||||||
// Use background hook
|
// Use background hook
|
||||||
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
|
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
|
||||||
currentProject,
|
currentProject,
|
||||||
@@ -1306,6 +1323,8 @@ export function BoardView() {
|
|||||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||||
completedCount={completedFeatures.length}
|
completedCount={completedFeatures.length}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
||||||
@@ -1344,48 +1363,89 @@ export function BoardView() {
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* View Content - Kanban Board */}
|
{/* View Content - Kanban Board or List View */}
|
||||||
<KanbanBoard
|
{isListView ? (
|
||||||
sensors={sensors}
|
<ListView
|
||||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
columnFeaturesMap={columnFeaturesMap}
|
||||||
onDragStart={handleDragStart}
|
allFeatures={hookFeatures}
|
||||||
onDragEnd={handleDragEnd}
|
sortConfig={sortConfig}
|
||||||
activeFeature={activeFeature}
|
onSortChange={setSortColumn}
|
||||||
getColumnFeatures={getColumnFeatures}
|
actionHandlers={{
|
||||||
backgroundImageStyle={backgroundImageStyle}
|
onEdit: (feature) => setEditingFeature(feature),
|
||||||
backgroundSettings={backgroundSettings}
|
onDelete: (featureId) => handleDeleteFeature(featureId),
|
||||||
onEdit={(feature) => setEditingFeature(feature)}
|
onViewOutput: handleViewOutput,
|
||||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
onVerify: handleVerifyFeature,
|
||||||
onViewOutput={handleViewOutput}
|
onResume: handleResumeFeature,
|
||||||
onVerify={handleVerifyFeature}
|
onForceStop: handleForceStopFeature,
|
||||||
onResume={handleResumeFeature}
|
onManualVerify: handleManualVerify,
|
||||||
onForceStop={handleForceStopFeature}
|
onFollowUp: handleOpenFollowUp,
|
||||||
onManualVerify={handleManualVerify}
|
onImplement: handleStartImplementation,
|
||||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
onComplete: handleCompleteFeature,
|
||||||
onFollowUp={handleOpenFollowUp}
|
onViewPlan: (feature) => setViewPlanFeature(feature),
|
||||||
onComplete={handleCompleteFeature}
|
onApprovePlan: handleOpenApprovalDialog,
|
||||||
onImplement={handleStartImplementation}
|
onSpawnTask: (feature) => {
|
||||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
setSpawnParentFeature(feature);
|
||||||
onApprovePlan={handleOpenApprovalDialog}
|
setShowAddDialog(true);
|
||||||
onSpawnTask={(feature) => {
|
},
|
||||||
setSpawnParentFeature(feature);
|
}}
|
||||||
setShowAddDialog(true);
|
runningAutoTasks={runningAutoTasks}
|
||||||
}}
|
pipelineConfig={pipelineConfig}
|
||||||
featuresWithContext={featuresWithContext}
|
onAddFeature={() => setShowAddDialog(true)}
|
||||||
runningAutoTasks={runningAutoTasks}
|
isSelectionMode={isSelectionMode}
|
||||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
onAddFeature={() => setShowAddDialog(true)}
|
onToggleFeatureSelection={toggleFeatureSelection}
|
||||||
pipelineConfig={
|
onRowClick={(feature) => {
|
||||||
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
|
if (feature.status === 'backlog') {
|
||||||
}
|
setEditingFeature(feature);
|
||||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
} else {
|
||||||
isSelectionMode={isSelectionMode}
|
handleViewOutput(feature);
|
||||||
selectedFeatureIds={selectedFeatureIds}
|
}
|
||||||
onToggleFeatureSelection={toggleFeatureSelection}
|
}}
|
||||||
onToggleSelectionMode={toggleSelectionMode}
|
className="transition-opacity duration-200"
|
||||||
isDragging={activeFeature !== null}
|
/>
|
||||||
onAiSuggest={() => setShowPlanDialog(true)}
|
) : (
|
||||||
/>
|
<KanbanBoard
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
activeFeature={activeFeature}
|
||||||
|
getColumnFeatures={getColumnFeatures}
|
||||||
|
backgroundImageStyle={backgroundImageStyle}
|
||||||
|
backgroundSettings={backgroundSettings}
|
||||||
|
onEdit={(feature) => setEditingFeature(feature)}
|
||||||
|
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||||
|
onViewOutput={handleViewOutput}
|
||||||
|
onVerify={handleVerifyFeature}
|
||||||
|
onResume={handleResumeFeature}
|
||||||
|
onForceStop={handleForceStopFeature}
|
||||||
|
onManualVerify={handleManualVerify}
|
||||||
|
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||||
|
onFollowUp={handleOpenFollowUp}
|
||||||
|
onComplete={handleCompleteFeature}
|
||||||
|
onImplement={handleStartImplementation}
|
||||||
|
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||||
|
onApprovePlan={handleOpenApprovalDialog}
|
||||||
|
onSpawnTask={(feature) => {
|
||||||
|
setSpawnParentFeature(feature);
|
||||||
|
setShowAddDialog(true);
|
||||||
|
}}
|
||||||
|
featuresWithContext={featuresWithContext}
|
||||||
|
runningAutoTasks={runningAutoTasks}
|
||||||
|
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||||
|
onAddFeature={() => setShowAddDialog(true)}
|
||||||
|
pipelineConfig={pipelineConfig}
|
||||||
|
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||||
|
isSelectionMode={isSelectionMode}
|
||||||
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
|
onToggleFeatureSelection={toggleFeatureSelection}
|
||||||
|
onToggleSelectionMode={toggleSelectionMode}
|
||||||
|
viewMode={viewMode}
|
||||||
|
isDragging={activeFeature !== null}
|
||||||
|
onAiSuggest={() => setShowPlanDialog(true)}
|
||||||
|
className="transition-opacity duration-200"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selection Action Bar */}
|
{/* Selection Action Bar */}
|
||||||
@@ -1507,7 +1567,7 @@ export function BoardView() {
|
|||||||
open={showPipelineSettings}
|
open={showPipelineSettings}
|
||||||
onClose={() => setShowPipelineSettings(false)}
|
onClose={() => setShowPipelineSettings(false)}
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
pipelineConfig={pipelineConfigByProject[currentProject.path] || null}
|
pipelineConfig={pipelineConfig}
|
||||||
onSave={async (config) => {
|
onSave={async (config) => {
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const result = await api.pipeline.saveConfig(currentProject.path, config);
|
const result = await api.pipeline.saveConfig(currentProject.path, config);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function BoardControls({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-5">
|
||||||
{/* Board Background Button */}
|
{/* Board Background Button */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
|
|||||||
import { UsagePopover } from '@/components/usage-popover';
|
import { UsagePopover } from '@/components/usage-popover';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
||||||
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
|
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
|
||||||
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
|
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { BoardSearchBar } from './board-search-bar';
|
import { BoardSearchBar } from './board-search-bar';
|
||||||
import { BoardControls } from './board-controls';
|
import { BoardControls } from './board-controls';
|
||||||
|
import { ViewToggle, type ViewMode } from './components';
|
||||||
|
import { HeaderMobileMenu } from './header-mobile-menu';
|
||||||
|
|
||||||
|
export type { ViewMode };
|
||||||
|
|
||||||
interface BoardHeaderProps {
|
interface BoardHeaderProps {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
@@ -33,6 +38,9 @@ interface BoardHeaderProps {
|
|||||||
onShowBoardBackground: () => void;
|
onShowBoardBackground: () => void;
|
||||||
onShowCompletedModal: () => void;
|
onShowCompletedModal: () => void;
|
||||||
completedCount: number;
|
completedCount: number;
|
||||||
|
// View toggle props
|
||||||
|
viewMode: ViewMode;
|
||||||
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared styles for header control containers
|
// Shared styles for header control containers
|
||||||
@@ -55,11 +63,12 @@ export function BoardHeader({
|
|||||||
onShowBoardBackground,
|
onShowBoardBackground,
|
||||||
onShowCompletedModal,
|
onShowCompletedModal,
|
||||||
completedCount,
|
completedCount,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
}: BoardHeaderProps) {
|
}: BoardHeaderProps) {
|
||||||
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
||||||
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
|
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
|
||||||
const [showPlanSettings, setShowPlanSettings] = useState(false);
|
const [showPlanSettings, setShowPlanSettings] = useState(false);
|
||||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
|
||||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
||||||
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
|
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
|
||||||
@@ -98,22 +107,17 @@ export function BoardHeader({
|
|||||||
[projectPath, setWorktreePanelVisible]
|
[projectPath, setWorktreePanelVisible]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Claude usage tracking visibility logic
|
const isClaudeCliVerified = !!claudeAuthStatus?.authenticated;
|
||||||
// Hide when using API key (only show for Claude Code CLI users)
|
const showClaudeUsage = isClaudeCliVerified;
|
||||||
// Also hide on Windows for now (CLI usage command not supported)
|
|
||||||
const isWindows =
|
|
||||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
|
||||||
const hasClaudeApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
|
||||||
const isClaudeCliVerified =
|
|
||||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
|
||||||
const showClaudeUsage = !hasClaudeApiKey && !isWindows && isClaudeCliVerified;
|
|
||||||
|
|
||||||
// Codex usage tracking visibility logic
|
// Codex usage tracking visibility logic
|
||||||
// Show if Codex is authenticated (CLI or API key)
|
// Show if Codex is authenticated (CLI or API key)
|
||||||
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<BoardSearchBar
|
<BoardSearchBar
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
@@ -122,6 +126,7 @@ export function BoardHeader({
|
|||||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||||
currentProjectPath={projectPath}
|
currentProjectPath={projectPath}
|
||||||
/>
|
/>
|
||||||
|
{isMounted && <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} />}
|
||||||
<BoardControls
|
<BoardControls
|
||||||
isMounted={isMounted}
|
isMounted={isMounted}
|
||||||
onShowBoardBackground={onShowBoardBackground}
|
onShowBoardBackground={onShowBoardBackground}
|
||||||
@@ -129,12 +134,30 @@ export function BoardHeader({
|
|||||||
completedCount={completedCount}
|
completedCount={completedCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
{/* Usage Popover - show if either provider is authenticated */}
|
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||||
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
{isMounted && !isMobile && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||||
|
|
||||||
|
{/* Mobile view: show hamburger menu with all controls */}
|
||||||
|
{isMounted && isMobile && (
|
||||||
|
<HeaderMobileMenu
|
||||||
|
isWorktreePanelVisible={isWorktreePanelVisible}
|
||||||
|
onWorktreePanelToggle={handleWorktreePanelToggle}
|
||||||
|
maxConcurrency={maxConcurrency}
|
||||||
|
runningAgentsCount={runningAgentsCount}
|
||||||
|
onConcurrencyChange={onConcurrencyChange}
|
||||||
|
isAutoModeRunning={isAutoModeRunning}
|
||||||
|
onAutoModeToggle={onAutoModeToggle}
|
||||||
|
onOpenAutoModeSettings={() => setShowAutoModeSettings(true)}
|
||||||
|
onOpenPlanDialog={onOpenPlanDialog}
|
||||||
|
showClaudeUsage={showClaudeUsage}
|
||||||
|
showCodexUsage={showCodexUsage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop view: show full controls */}
|
||||||
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
|
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
|
||||||
{isMounted && (
|
{isMounted && !isMobile && (
|
||||||
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
||||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||||
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
|
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
|
||||||
@@ -166,7 +189,7 @@ export function BoardHeader({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Concurrency Control - only show after mount to prevent hydration issues */}
|
{/* Concurrency Control - only show after mount to prevent hydration issues */}
|
||||||
{isMounted && (
|
{isMounted && !isMobile && (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -209,7 +232,7 @@ export function BoardHeader({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||||
{isMounted && (
|
{isMounted && !isMobile && (
|
||||||
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
|
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
|
||||||
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
|
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
|
||||||
Auto Mode
|
Auto Mode
|
||||||
@@ -239,25 +262,27 @@ export function BoardHeader({
|
|||||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Plan Button with Settings */}
|
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
|
||||||
<div className={controlContainerClass} data-testid="plan-button-container">
|
{isMounted && !isMobile && (
|
||||||
<button
|
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||||
onClick={onOpenPlanDialog}
|
<button
|
||||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
onClick={onOpenPlanDialog}
|
||||||
data-testid="plan-backlog-button"
|
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||||
>
|
data-testid="plan-backlog-button"
|
||||||
<Wand2 className="w-4 h-4 text-muted-foreground" />
|
>
|
||||||
<span className="text-sm font-medium">Plan</span>
|
<Wand2 className="w-4 h-4 text-muted-foreground" />
|
||||||
</button>
|
<span className="text-sm font-medium">Plan</span>
|
||||||
<button
|
</button>
|
||||||
onClick={() => setShowPlanSettings(true)}
|
<button
|
||||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
onClick={() => setShowPlanSettings(true)}
|
||||||
title="Plan Settings"
|
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||||
data-testid="plan-settings-button"
|
title="Plan Settings"
|
||||||
>
|
data-testid="plan-settings-button"
|
||||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
>
|
||||||
</button>
|
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Plan Settings Dialog */}
|
{/* Plan Settings Dialog */}
|
||||||
<PlanSettingsDialog
|
<PlanSettingsDialog
|
||||||
|
|||||||
@@ -2,3 +2,33 @@ export { KanbanCard } from './kanban-card/kanban-card';
|
|||||||
export { KanbanColumn } from './kanban-column';
|
export { KanbanColumn } from './kanban-column';
|
||||||
export { SelectionActionBar } from './selection-action-bar';
|
export { SelectionActionBar } from './selection-action-bar';
|
||||||
export { EmptyStateCard } from './empty-state-card';
|
export { EmptyStateCard } from './empty-state-card';
|
||||||
|
export { ViewToggle, type ViewMode } from './view-toggle';
|
||||||
|
|
||||||
|
// List view components
|
||||||
|
export {
|
||||||
|
ListHeader,
|
||||||
|
LIST_COLUMNS,
|
||||||
|
getColumnById,
|
||||||
|
getColumnWidth,
|
||||||
|
getColumnAlign,
|
||||||
|
ListRow,
|
||||||
|
getFeatureSortValue,
|
||||||
|
sortFeatures,
|
||||||
|
ListView,
|
||||||
|
getFlatFeatures,
|
||||||
|
getTotalFeatureCount,
|
||||||
|
RowActions,
|
||||||
|
createRowActionHandlers,
|
||||||
|
StatusBadge,
|
||||||
|
getStatusLabel,
|
||||||
|
getStatusOrder,
|
||||||
|
} from './list-view';
|
||||||
|
export type {
|
||||||
|
ListHeaderProps,
|
||||||
|
ListRowProps,
|
||||||
|
ListViewProps,
|
||||||
|
ListViewActionHandlers,
|
||||||
|
RowActionsProps,
|
||||||
|
RowActionHandlers,
|
||||||
|
StatusBadgeProps,
|
||||||
|
} from './list-view';
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export {
|
||||||
|
ListHeader,
|
||||||
|
LIST_COLUMNS,
|
||||||
|
getColumnById,
|
||||||
|
getColumnWidth,
|
||||||
|
getColumnAlign,
|
||||||
|
} from './list-header';
|
||||||
|
export type { ListHeaderProps } from './list-header';
|
||||||
|
|
||||||
|
export { ListRow, getFeatureSortValue, sortFeatures } from './list-row';
|
||||||
|
export type { ListRowProps } from './list-row';
|
||||||
|
|
||||||
|
export { ListView, getFlatFeatures, getTotalFeatureCount } from './list-view';
|
||||||
|
export type { ListViewProps, ListViewActionHandlers } from './list-view';
|
||||||
|
|
||||||
|
export { RowActions, createRowActionHandlers } from './row-actions';
|
||||||
|
export type { RowActionsProps, RowActionHandlers } from './row-actions';
|
||||||
|
|
||||||
|
export { StatusBadge, getStatusLabel, getStatusOrder } from './status-badge';
|
||||||
|
export type { StatusBadgeProps } from './status-badge';
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { memo, useCallback } from 'react';
|
||||||
|
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { SortColumn, SortConfig, SortDirection } from '../../hooks/use-list-view-state';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column definition for the list header
|
||||||
|
*/
|
||||||
|
interface ColumnDef {
|
||||||
|
id: SortColumn;
|
||||||
|
label: string;
|
||||||
|
/** Whether this column is sortable */
|
||||||
|
sortable?: boolean;
|
||||||
|
/** Minimum width for the column */
|
||||||
|
minWidth?: string;
|
||||||
|
/** Width class for the column */
|
||||||
|
width?: string;
|
||||||
|
/** Alignment of the column content */
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
/** Additional className for the column */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default column definitions for the list view
|
||||||
|
* Only showing title column with full width for a cleaner, more spacious layout
|
||||||
|
*/
|
||||||
|
export const LIST_COLUMNS: ColumnDef[] = [
|
||||||
|
{
|
||||||
|
id: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
sortable: true,
|
||||||
|
width: 'flex-1',
|
||||||
|
minWidth: 'min-w-0',
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ListHeaderProps {
|
||||||
|
/** Current sort configuration */
|
||||||
|
sortConfig: SortConfig;
|
||||||
|
/** Callback when a sortable column is clicked */
|
||||||
|
onSortChange: (column: SortColumn) => void;
|
||||||
|
/** Whether to show a checkbox column for selection */
|
||||||
|
showCheckbox?: boolean;
|
||||||
|
/** Whether all items are selected (for checkbox state) */
|
||||||
|
allSelected?: boolean;
|
||||||
|
/** Whether some but not all items are selected */
|
||||||
|
someSelected?: boolean;
|
||||||
|
/** Callback when the select all checkbox is clicked */
|
||||||
|
onSelectAll?: () => void;
|
||||||
|
/** Custom column definitions (defaults to LIST_COLUMNS) */
|
||||||
|
columns?: ColumnDef[];
|
||||||
|
/** Additional className for the header */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SortIcon displays the current sort state for a column
|
||||||
|
*/
|
||||||
|
function SortIcon({ column, sortConfig }: { column: SortColumn; sortConfig: SortConfig }) {
|
||||||
|
if (sortConfig.column !== column) {
|
||||||
|
// Not sorted by this column - show neutral indicator
|
||||||
|
return (
|
||||||
|
<ChevronsUpDown className="w-3.5 h-3.5 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently sorted by this column
|
||||||
|
if (sortConfig.direction === 'asc') {
|
||||||
|
return <ChevronUp className="w-3.5 h-3.5 text-foreground" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ChevronDown className="w-3.5 h-3.5 text-foreground" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SortableColumnHeader renders a clickable header cell that triggers sorting
|
||||||
|
*/
|
||||||
|
const SortableColumnHeader = memo(function SortableColumnHeader({
|
||||||
|
column,
|
||||||
|
sortConfig,
|
||||||
|
onSortChange,
|
||||||
|
}: {
|
||||||
|
column: ColumnDef;
|
||||||
|
sortConfig: SortConfig;
|
||||||
|
onSortChange: (column: SortColumn) => void;
|
||||||
|
}) {
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onSortChange(column.id);
|
||||||
|
}, [column.id, onSortChange]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onSortChange(column.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[column.id, onSortChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSorted = sortConfig.column === column.id;
|
||||||
|
const sortDirection: SortDirection | undefined = isSorted ? sortConfig.direction : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="columnheader"
|
||||||
|
aria-sort={isSorted ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-muted-foreground',
|
||||||
|
'cursor-pointer select-none transition-colors duration-200',
|
||||||
|
'hover:text-foreground hover:bg-accent/50 rounded-md',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||||
|
column.width,
|
||||||
|
column.minWidth,
|
||||||
|
column.align === 'center' && 'justify-center',
|
||||||
|
column.align === 'right' && 'justify-end',
|
||||||
|
isSorted && 'text-foreground',
|
||||||
|
column.className
|
||||||
|
)}
|
||||||
|
data-testid={`list-header-${column.id}`}
|
||||||
|
>
|
||||||
|
<span>{column.label}</span>
|
||||||
|
<SortIcon column={column.id} sortConfig={sortConfig} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StaticColumnHeader renders a non-sortable header cell
|
||||||
|
*/
|
||||||
|
const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column: ColumnDef }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="columnheader"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
|
||||||
|
column.width,
|
||||||
|
column.minWidth,
|
||||||
|
column.align === 'center' && 'justify-center',
|
||||||
|
column.align === 'right' && 'justify-end',
|
||||||
|
column.className
|
||||||
|
)}
|
||||||
|
data-testid={`list-header-${column.id}`}
|
||||||
|
>
|
||||||
|
<span>{column.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ListHeader displays the header row for the list view table with sortable columns.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Clickable column headers for sorting
|
||||||
|
* - Visual sort direction indicators (chevron up/down)
|
||||||
|
* - Keyboard accessible (Tab + Enter/Space to sort)
|
||||||
|
* - ARIA attributes for screen readers
|
||||||
|
* - Optional checkbox column for bulk selection
|
||||||
|
* - Customizable column definitions
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { sortConfig, setSortColumn } = useListViewState();
|
||||||
|
*
|
||||||
|
* <ListHeader
|
||||||
|
* sortConfig={sortConfig}
|
||||||
|
* onSortChange={setSortColumn}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // With selection support
|
||||||
|
* <ListHeader
|
||||||
|
* sortConfig={sortConfig}
|
||||||
|
* onSortChange={setSortColumn}
|
||||||
|
* showCheckbox
|
||||||
|
* allSelected={allSelected}
|
||||||
|
* someSelected={someSelected}
|
||||||
|
* onSelectAll={handleSelectAll}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const ListHeader = memo(function ListHeader({
|
||||||
|
sortConfig,
|
||||||
|
onSortChange,
|
||||||
|
showCheckbox = false,
|
||||||
|
allSelected = false,
|
||||||
|
someSelected = false,
|
||||||
|
onSelectAll,
|
||||||
|
columns = LIST_COLUMNS,
|
||||||
|
className,
|
||||||
|
}: ListHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="row"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center w-full border-b border-border bg-muted/30',
|
||||||
|
'sticky top-0 z-10 backdrop-blur-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid="list-header"
|
||||||
|
>
|
||||||
|
{/* Checkbox column for selection */}
|
||||||
|
{showCheckbox && (
|
||||||
|
<div
|
||||||
|
role="columnheader"
|
||||||
|
className="flex items-center justify-center w-10 px-2 py-2 shrink-0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) {
|
||||||
|
el.indeterminate = someSelected && !allSelected;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={onSelectAll}
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 rounded border-border text-primary cursor-pointer',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'
|
||||||
|
)}
|
||||||
|
aria-label={allSelected ? 'Deselect all' : 'Select all'}
|
||||||
|
data-testid="list-header-select-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Column headers */}
|
||||||
|
{columns.map((column) =>
|
||||||
|
column.sortable !== false ? (
|
||||||
|
<SortableColumnHeader
|
||||||
|
key={column.id}
|
||||||
|
column={column}
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSortChange={onSortChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StaticColumnHeader key={column.id} column={column} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions column (placeholder for row action buttons) */}
|
||||||
|
<div
|
||||||
|
role="columnheader"
|
||||||
|
className="w-[80px] px-3 py-2 text-xs font-medium text-muted-foreground shrink-0"
|
||||||
|
aria-label="Actions"
|
||||||
|
data-testid="list-header-actions"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get a column definition by ID
|
||||||
|
*/
|
||||||
|
export function getColumnById(columnId: SortColumn): ColumnDef | undefined {
|
||||||
|
return LIST_COLUMNS.find((col) => col.id === columnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get column width class for consistent styling in rows
|
||||||
|
*/
|
||||||
|
export function getColumnWidth(columnId: SortColumn): string {
|
||||||
|
const column = getColumnById(columnId);
|
||||||
|
return cn(column?.width, column?.minWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get column alignment class
|
||||||
|
*/
|
||||||
|
export function getColumnAlign(columnId: SortColumn): string {
|
||||||
|
const column = getColumnById(columnId);
|
||||||
|
if (column?.align === 'center') return 'justify-center text-center';
|
||||||
|
if (column?.align === 'right') return 'justify-end text-right';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
// TODO: Remove @ts-nocheck after fixing BaseFeature's index signature issue
|
||||||
|
// The `[key: string]: unknown` in BaseFeature causes property access type errors
|
||||||
|
// @ts-nocheck
|
||||||
|
import { memo, useCallback, useState, useEffect } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react';
|
||||||
|
import type { Feature } from '@/store/app-store';
|
||||||
|
import { RowActions, type RowActionHandlers } from './row-actions';
|
||||||
|
import { getColumnWidth, getColumnAlign } from './list-header';
|
||||||
|
|
||||||
|
export interface ListRowProps {
|
||||||
|
/** The feature to display */
|
||||||
|
feature: Feature;
|
||||||
|
/** Action handlers for the row */
|
||||||
|
handlers: RowActionHandlers;
|
||||||
|
/** Whether this feature is the current auto task (agent is running) */
|
||||||
|
isCurrentAutoTask?: boolean;
|
||||||
|
/** Whether the row is selected */
|
||||||
|
isSelected?: boolean;
|
||||||
|
/** Whether to show the checkbox for selection */
|
||||||
|
showCheckbox?: boolean;
|
||||||
|
/** Callback when the row selection is toggled */
|
||||||
|
onToggleSelect?: () => void;
|
||||||
|
/** Callback when the row is clicked */
|
||||||
|
onClick?: () => void;
|
||||||
|
/** Blocking dependency feature IDs */
|
||||||
|
blockingDependencies?: string[];
|
||||||
|
/** Additional className for custom styling */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IndicatorBadges shows small indicator icons for special states (error, blocked, manual verification, just finished)
|
||||||
|
*/
|
||||||
|
const IndicatorBadges = memo(function IndicatorBadges({
|
||||||
|
feature,
|
||||||
|
blockingDependencies = [],
|
||||||
|
isCurrentAutoTask,
|
||||||
|
}: {
|
||||||
|
feature: Feature;
|
||||||
|
blockingDependencies?: string[];
|
||||||
|
isCurrentAutoTask?: boolean;
|
||||||
|
}) {
|
||||||
|
const hasError = feature.error && !isCurrentAutoTask;
|
||||||
|
const isBlocked =
|
||||||
|
blockingDependencies.length > 0 && !feature.error && feature.status === 'backlog';
|
||||||
|
const showManualVerification =
|
||||||
|
feature.skipTests && !feature.error && feature.status === 'backlog';
|
||||||
|
const hasPlan = feature.planSpec?.content;
|
||||||
|
|
||||||
|
// Check if just finished (within 2 minutes) - uses timer to auto-expire
|
||||||
|
const [isJustFinished, setIsJustFinished] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
|
||||||
|
setIsJustFinished(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishedTime = new Date(feature.justFinishedAt).getTime();
|
||||||
|
const twoMinutes = 2 * 60 * 1000;
|
||||||
|
const elapsed = Date.now() - finishedTime;
|
||||||
|
|
||||||
|
if (elapsed >= twoMinutes) {
|
||||||
|
setIsJustFinished(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set as just finished
|
||||||
|
setIsJustFinished(true);
|
||||||
|
|
||||||
|
// Set a timeout to clear the "just finished" state when 2 minutes have passed
|
||||||
|
const remainingTime = twoMinutes - elapsed;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
setIsJustFinished(false);
|
||||||
|
}, remainingTime);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [feature.justFinishedAt, feature.status, feature.error]);
|
||||||
|
|
||||||
|
const badges: Array<{
|
||||||
|
key: string;
|
||||||
|
icon: typeof AlertCircle;
|
||||||
|
tooltip: string;
|
||||||
|
colorClass: string;
|
||||||
|
bgClass: string;
|
||||||
|
borderClass: string;
|
||||||
|
animate?: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
badges.push({
|
||||||
|
key: 'error',
|
||||||
|
icon: AlertCircle,
|
||||||
|
tooltip: feature.error || 'Error',
|
||||||
|
colorClass: 'text-[var(--status-error)]',
|
||||||
|
bgClass: 'bg-[var(--status-error)]/15',
|
||||||
|
borderClass: 'border-[var(--status-error)]/30',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBlocked) {
|
||||||
|
badges.push({
|
||||||
|
key: 'blocked',
|
||||||
|
icon: Lock,
|
||||||
|
tooltip: `Blocked by ${blockingDependencies.length} incomplete ${blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}`,
|
||||||
|
colorClass: 'text-orange-500',
|
||||||
|
bgClass: 'bg-orange-500/15',
|
||||||
|
borderClass: 'border-orange-500/30',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showManualVerification) {
|
||||||
|
badges.push({
|
||||||
|
key: 'manual',
|
||||||
|
icon: Hand,
|
||||||
|
tooltip: 'Manual verification required',
|
||||||
|
colorClass: 'text-[var(--status-warning)]',
|
||||||
|
bgClass: 'bg-[var(--status-warning)]/15',
|
||||||
|
borderClass: 'border-[var(--status-warning)]/30',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPlan) {
|
||||||
|
badges.push({
|
||||||
|
key: 'plan',
|
||||||
|
icon: FileText,
|
||||||
|
tooltip: 'Has implementation plan',
|
||||||
|
colorClass: 'text-[var(--status-info)]',
|
||||||
|
bgClass: 'bg-[var(--status-info)]/15',
|
||||||
|
borderClass: 'border-[var(--status-info)]/30',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJustFinished) {
|
||||||
|
badges.push({
|
||||||
|
key: 'just-finished',
|
||||||
|
icon: Sparkles,
|
||||||
|
tooltip: 'Agent just finished working on this feature',
|
||||||
|
colorClass: 'text-[var(--status-success)]',
|
||||||
|
bgClass: 'bg-[var(--status-success)]/15',
|
||||||
|
borderClass: 'border-[var(--status-success)]/30',
|
||||||
|
animate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badges.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
{badges.map((badge) => (
|
||||||
|
<Tooltip key={badge.key}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center w-5 h-5 rounded border',
|
||||||
|
badge.colorClass,
|
||||||
|
badge.bgClass,
|
||||||
|
badge.borderClass,
|
||||||
|
badge.animate && 'animate-pulse'
|
||||||
|
)}
|
||||||
|
data-testid={`list-row-badge-${badge.key}`}
|
||||||
|
>
|
||||||
|
<badge.icon className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-xs max-w-[250px]">
|
||||||
|
<p>{badge.tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ListRow displays a single feature row in the list view table.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Displays feature data in columns matching ListHeader
|
||||||
|
* - Hover state with highlight and action buttons
|
||||||
|
* - Click handler for opening feature details
|
||||||
|
* - Animated border for currently running auto task
|
||||||
|
* - Status badge with appropriate colors
|
||||||
|
* - Priority indicator
|
||||||
|
* - Indicator badges for errors, blocked state, manual verification, etc.
|
||||||
|
* - Selection checkbox for bulk operations
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <ListRow
|
||||||
|
* feature={feature}
|
||||||
|
* handlers={{
|
||||||
|
* onEdit: () => handleEdit(feature.id),
|
||||||
|
* onDelete: () => handleDelete(feature.id),
|
||||||
|
* // ... other handlers
|
||||||
|
* }}
|
||||||
|
* onClick={() => handleViewDetails(feature)}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const ListRow = memo(function ListRow({
|
||||||
|
feature,
|
||||||
|
handlers,
|
||||||
|
isCurrentAutoTask = false,
|
||||||
|
isSelected = false,
|
||||||
|
showCheckbox = false,
|
||||||
|
onToggleSelect,
|
||||||
|
onClick,
|
||||||
|
blockingDependencies = [],
|
||||||
|
className,
|
||||||
|
}: ListRowProps) {
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
// Don't trigger row click if clicking on checkbox or actions
|
||||||
|
if ((e.target as HTMLElement).closest('[data-testid^="row-actions"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.target as HTMLElement).closest('input[type="checkbox"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClick?.();
|
||||||
|
},
|
||||||
|
[onClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCheckboxChange = useCallback(() => {
|
||||||
|
onToggleSelect?.();
|
||||||
|
}, [onToggleSelect]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasError = feature.error && !isCurrentAutoTask;
|
||||||
|
|
||||||
|
const rowContent = (
|
||||||
|
<div
|
||||||
|
role="row"
|
||||||
|
tabIndex={onClick ? 0 : undefined}
|
||||||
|
onClick={handleRowClick}
|
||||||
|
onKeyDown={onClick ? handleKeyDown : undefined}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center w-full border-b border-border/50',
|
||||||
|
'transition-colors duration-200',
|
||||||
|
onClick && 'cursor-pointer',
|
||||||
|
'hover:bg-accent/50',
|
||||||
|
isSelected && 'bg-accent/70',
|
||||||
|
hasError && 'bg-[var(--status-error)]/5 hover:bg-[var(--status-error)]/10',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid={`list-row-${feature.id}`}
|
||||||
|
>
|
||||||
|
{/* Checkbox column */}
|
||||||
|
{showCheckbox && (
|
||||||
|
<div role="cell" className="flex items-center justify-center w-10 px-2 py-3 shrink-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 rounded border-border text-primary cursor-pointer',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'
|
||||||
|
)}
|
||||||
|
aria-label={`Select ${feature.title || feature.description}`}
|
||||||
|
data-testid={`list-row-checkbox-${feature.id}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title column - full width with margin for actions */}
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center px-3 py-3 gap-2',
|
||||||
|
getColumnWidth('title'),
|
||||||
|
getColumnAlign('title')
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-medium truncate',
|
||||||
|
feature.titleGenerating && 'animate-pulse text-muted-foreground'
|
||||||
|
)}
|
||||||
|
title={feature.title || feature.description}
|
||||||
|
>
|
||||||
|
{feature.title || feature.description}
|
||||||
|
</span>
|
||||||
|
<IndicatorBadges
|
||||||
|
feature={feature}
|
||||||
|
blockingDependencies={blockingDependencies}
|
||||||
|
isCurrentAutoTask={isCurrentAutoTask}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Show description as subtitle if title exists and is different */}
|
||||||
|
{feature.title && feature.title !== feature.description && (
|
||||||
|
<p
|
||||||
|
className="text-xs text-muted-foreground truncate mt-0.5"
|
||||||
|
title={feature.description}
|
||||||
|
>
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions column */}
|
||||||
|
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
|
||||||
|
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap with animated border for currently running auto task
|
||||||
|
if (isCurrentAutoTask) {
|
||||||
|
return <div className="animated-border-wrapper-row">{rowContent}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowContent;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get feature sort value for a column
|
||||||
|
*/
|
||||||
|
export function getFeatureSortValue(
|
||||||
|
feature: Feature,
|
||||||
|
column: 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt'
|
||||||
|
): string | number | Date {
|
||||||
|
switch (column) {
|
||||||
|
case 'title':
|
||||||
|
return (feature.title || feature.description).toLowerCase();
|
||||||
|
case 'status':
|
||||||
|
return feature.status;
|
||||||
|
case 'category':
|
||||||
|
return (feature.category || '').toLowerCase();
|
||||||
|
case 'priority':
|
||||||
|
return feature.priority || 999; // No priority sorts last
|
||||||
|
case 'createdAt':
|
||||||
|
return feature.createdAt ? new Date(feature.createdAt) : new Date(0);
|
||||||
|
case 'updatedAt':
|
||||||
|
return feature.updatedAt ? new Date(feature.updatedAt) : new Date(0);
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to sort features by a column
|
||||||
|
*/
|
||||||
|
export function sortFeatures(
|
||||||
|
features: Feature[],
|
||||||
|
column: 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt',
|
||||||
|
direction: 'asc' | 'desc'
|
||||||
|
): Feature[] {
|
||||||
|
return [...features].sort((a, b) => {
|
||||||
|
const aValue = getFeatureSortValue(a, column);
|
||||||
|
const bValue = getFeatureSortValue(b, column);
|
||||||
|
|
||||||
|
let comparison = 0;
|
||||||
|
|
||||||
|
if (aValue instanceof Date && bValue instanceof Date) {
|
||||||
|
comparison = aValue.getTime() - bValue.getTime();
|
||||||
|
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
comparison = aValue - bValue;
|
||||||
|
} else {
|
||||||
|
comparison = String(aValue).localeCompare(String(bValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
return direction === 'asc' ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
import { memo, useMemo, useCallback, useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
|
import type { Feature } from '@/store/app-store';
|
||||||
|
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
|
||||||
|
import { ListHeader } from './list-header';
|
||||||
|
import { ListRow, sortFeatures } from './list-row';
|
||||||
|
import { createRowActionHandlers, type RowActionHandlers } from './row-actions';
|
||||||
|
import { getStatusLabel, getStatusOrder } from './status-badge';
|
||||||
|
import { getColumnsWithPipeline } from '../../constants';
|
||||||
|
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';
|
||||||
|
|
||||||
|
/** Empty set constant to avoid creating new instances on each render */
|
||||||
|
const EMPTY_SET = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status group configuration for the list view
|
||||||
|
*/
|
||||||
|
interface StatusGroup {
|
||||||
|
id: FeatureStatusWithPipeline;
|
||||||
|
title: string;
|
||||||
|
colorClass: string;
|
||||||
|
features: Feature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for action handlers passed from the parent board view
|
||||||
|
*/
|
||||||
|
export interface ListViewActionHandlers {
|
||||||
|
onEdit: (feature: Feature) => void;
|
||||||
|
onDelete: (featureId: string) => void;
|
||||||
|
onViewOutput?: (feature: Feature) => void;
|
||||||
|
onVerify?: (feature: Feature) => void;
|
||||||
|
onResume?: (feature: Feature) => void;
|
||||||
|
onForceStop?: (feature: Feature) => void;
|
||||||
|
onManualVerify?: (feature: Feature) => void;
|
||||||
|
onFollowUp?: (feature: Feature) => void;
|
||||||
|
onImplement?: (feature: Feature) => void;
|
||||||
|
onComplete?: (feature: Feature) => void;
|
||||||
|
onViewPlan?: (feature: Feature) => void;
|
||||||
|
onApprovePlan?: (feature: Feature) => void;
|
||||||
|
onSpawnTask?: (feature: Feature) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListViewProps {
|
||||||
|
/** Map of column/status ID to features in that column */
|
||||||
|
columnFeaturesMap: Record<string, Feature[]>;
|
||||||
|
/** All features (for dependency checking) */
|
||||||
|
allFeatures: Feature[];
|
||||||
|
/** Current sort configuration */
|
||||||
|
sortConfig: SortConfig;
|
||||||
|
/** Callback when sort column is changed */
|
||||||
|
onSortChange: (column: SortColumn) => void;
|
||||||
|
/** Action handlers for rows */
|
||||||
|
actionHandlers: ListViewActionHandlers;
|
||||||
|
/** Set of feature IDs that are currently running */
|
||||||
|
runningAutoTasks: string[];
|
||||||
|
/** Pipeline configuration for custom statuses */
|
||||||
|
pipelineConfig?: PipelineConfig | null;
|
||||||
|
/** Callback to add a new feature */
|
||||||
|
onAddFeature?: () => void;
|
||||||
|
/** Whether selection mode is enabled */
|
||||||
|
isSelectionMode?: boolean;
|
||||||
|
/** Set of selected feature IDs */
|
||||||
|
selectedFeatureIds?: Set<string>;
|
||||||
|
/** Callback when a feature's selection is toggled */
|
||||||
|
onToggleFeatureSelection?: (featureId: string) => void;
|
||||||
|
/** Callback when the row is clicked */
|
||||||
|
onRowClick?: (feature: Feature) => void;
|
||||||
|
/** Additional className for custom styling */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatusGroupHeader displays the header for a status group with collapse toggle
|
||||||
|
*/
|
||||||
|
const StatusGroupHeader = memo(function StatusGroupHeader({
|
||||||
|
group,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
group: StatusGroup;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 w-full px-3 py-2 text-left',
|
||||||
|
'bg-muted/50 hover:bg-muted/70 transition-colors duration-200',
|
||||||
|
'border-b border-border/50',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset'
|
||||||
|
)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
data-testid={`list-group-header-${group.id}`}
|
||||||
|
>
|
||||||
|
{/* Collapse indicator */}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status color indicator */}
|
||||||
|
<span
|
||||||
|
className={cn('w-2.5 h-2.5 rounded-full shrink-0', group.colorClass)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Group title */}
|
||||||
|
<span className="font-medium text-sm">{group.title}</span>
|
||||||
|
|
||||||
|
{/* Feature count */}
|
||||||
|
<span className="text-xs text-muted-foreground">({group.features.length})</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmptyState displays a message when there are no features
|
||||||
|
*/
|
||||||
|
const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center py-16 px-4',
|
||||||
|
'text-center text-muted-foreground'
|
||||||
|
)}
|
||||||
|
data-testid="list-view-empty"
|
||||||
|
>
|
||||||
|
<p className="text-sm mb-4">No features to display</p>
|
||||||
|
{onAddFeature && (
|
||||||
|
<Button variant="outline" size="sm" onClick={onAddFeature}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Feature
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ListView displays features in a table format grouped by status.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Groups features by status (backlog, in_progress, waiting_approval, verified, pipeline steps)
|
||||||
|
* - Collapsible status groups
|
||||||
|
* - Sortable columns (title, status, category, priority, dates)
|
||||||
|
* - Inline row actions with hover state
|
||||||
|
* - Selection support for bulk operations
|
||||||
|
* - Animated border for currently running features
|
||||||
|
* - Keyboard accessible
|
||||||
|
*
|
||||||
|
* The component receives features grouped by status via columnFeaturesMap
|
||||||
|
* and applies the current sort configuration within each group.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { sortConfig, setSortColumn } = useListViewState();
|
||||||
|
* const { columnFeaturesMap } = useBoardColumnFeatures({ features, ... });
|
||||||
|
*
|
||||||
|
* <ListView
|
||||||
|
* columnFeaturesMap={columnFeaturesMap}
|
||||||
|
* allFeatures={features}
|
||||||
|
* sortConfig={sortConfig}
|
||||||
|
* onSortChange={setSortColumn}
|
||||||
|
* actionHandlers={{
|
||||||
|
* onEdit: handleEdit,
|
||||||
|
* onDelete: handleDelete,
|
||||||
|
* // ...
|
||||||
|
* }}
|
||||||
|
* runningAutoTasks={runningAutoTasks}
|
||||||
|
* pipelineConfig={pipelineConfig}
|
||||||
|
* onAddFeature={handleAddFeature}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const ListView = memo(function ListView({
|
||||||
|
columnFeaturesMap,
|
||||||
|
allFeatures,
|
||||||
|
sortConfig,
|
||||||
|
onSortChange,
|
||||||
|
actionHandlers,
|
||||||
|
runningAutoTasks,
|
||||||
|
pipelineConfig = null,
|
||||||
|
onAddFeature,
|
||||||
|
isSelectionMode = false,
|
||||||
|
selectedFeatureIds = EMPTY_SET,
|
||||||
|
onToggleFeatureSelection,
|
||||||
|
onRowClick,
|
||||||
|
className,
|
||||||
|
}: ListViewProps) {
|
||||||
|
// Track collapsed state for each status group
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Generate status groups from columnFeaturesMap
|
||||||
|
const statusGroups = useMemo<StatusGroup[]>(() => {
|
||||||
|
const columns = getColumnsWithPipeline(pipelineConfig);
|
||||||
|
const groups: StatusGroup[] = [];
|
||||||
|
|
||||||
|
for (const column of columns) {
|
||||||
|
const features = columnFeaturesMap[column.id] || [];
|
||||||
|
if (features.length > 0) {
|
||||||
|
// Sort features within the group according to current sort config
|
||||||
|
const sortedFeatures = sortFeatures(features, sortConfig.column, sortConfig.direction);
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
id: column.id as FeatureStatusWithPipeline,
|
||||||
|
title: column.title,
|
||||||
|
colorClass: column.colorClass,
|
||||||
|
features: sortedFeatures,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort groups by status order
|
||||||
|
return groups.sort((a, b) => getStatusOrder(a.id) - getStatusOrder(b.id));
|
||||||
|
}, [columnFeaturesMap, pipelineConfig, sortConfig]);
|
||||||
|
|
||||||
|
// Calculate total feature count
|
||||||
|
const totalFeatures = useMemo(
|
||||||
|
() => statusGroups.reduce((sum, group) => sum + group.features.length, 0),
|
||||||
|
[statusGroups]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle group collapse state
|
||||||
|
const toggleGroup = useCallback((groupId: string) => {
|
||||||
|
setCollapsedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(groupId)) {
|
||||||
|
next.delete(groupId);
|
||||||
|
} else {
|
||||||
|
next.add(groupId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Create row action handlers for a feature
|
||||||
|
const createHandlers = useCallback(
|
||||||
|
(feature: Feature): RowActionHandlers => {
|
||||||
|
return createRowActionHandlers(feature.id, {
|
||||||
|
editFeature: (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onEdit(f);
|
||||||
|
},
|
||||||
|
deleteFeature: (id) => actionHandlers.onDelete(id),
|
||||||
|
viewOutput: actionHandlers.onViewOutput
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onViewOutput?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
verifyFeature: actionHandlers.onVerify
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onVerify?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
resumeFeature: actionHandlers.onResume
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onResume?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
forceStop: actionHandlers.onForceStop
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onForceStop?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
manualVerify: actionHandlers.onManualVerify
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onManualVerify?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
followUp: actionHandlers.onFollowUp
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onFollowUp?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
implement: actionHandlers.onImplement
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onImplement?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
complete: actionHandlers.onComplete
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onComplete?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
viewPlan: actionHandlers.onViewPlan
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onViewPlan?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
approvePlan: actionHandlers.onApprovePlan
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onApprovePlan?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
spawnTask: actionHandlers.onSpawnTask
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onSpawnTask?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[actionHandlers, allFeatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get blocking dependencies for a feature
|
||||||
|
const getBlockingDeps = useCallback(
|
||||||
|
(feature: Feature): string[] => {
|
||||||
|
return getBlockingDependencies(feature, allFeatures);
|
||||||
|
},
|
||||||
|
[allFeatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate selection state for header checkbox
|
||||||
|
const selectionState = useMemo(() => {
|
||||||
|
if (!isSelectionMode || totalFeatures === 0) {
|
||||||
|
return { allSelected: false, someSelected: false };
|
||||||
|
}
|
||||||
|
const selectedCount = selectedFeatureIds.size;
|
||||||
|
return {
|
||||||
|
allSelected: selectedCount === totalFeatures && selectedCount > 0,
|
||||||
|
someSelected: selectedCount > 0 && selectedCount < totalFeatures,
|
||||||
|
};
|
||||||
|
}, [isSelectionMode, totalFeatures, selectedFeatureIds]);
|
||||||
|
|
||||||
|
// Handle select all toggle
|
||||||
|
const handleSelectAll = useCallback(() => {
|
||||||
|
if (!onToggleFeatureSelection) return;
|
||||||
|
|
||||||
|
// If all selected, deselect all; otherwise select all
|
||||||
|
if (selectionState.allSelected) {
|
||||||
|
// Clear all selections
|
||||||
|
selectedFeatureIds.forEach((id) => onToggleFeatureSelection(id));
|
||||||
|
} else {
|
||||||
|
// Select all features that aren't already selected
|
||||||
|
for (const group of statusGroups) {
|
||||||
|
for (const feature of group.features) {
|
||||||
|
if (!selectedFeatureIds.has(feature.id)) {
|
||||||
|
onToggleFeatureSelection(feature.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onToggleFeatureSelection, selectionState.allSelected, selectedFeatureIds, statusGroups]);
|
||||||
|
|
||||||
|
// Show empty state if no features
|
||||||
|
if (totalFeatures === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col h-full bg-background', className)} data-testid="list-view">
|
||||||
|
<EmptyState onAddFeature={onAddFeature} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col h-full bg-background', className)}
|
||||||
|
role="table"
|
||||||
|
aria-label="Features list"
|
||||||
|
data-testid="list-view"
|
||||||
|
>
|
||||||
|
{/* Table header */}
|
||||||
|
<ListHeader
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSortChange={onSortChange}
|
||||||
|
showCheckbox={isSelectionMode}
|
||||||
|
allSelected={selectionState.allSelected}
|
||||||
|
someSelected={selectionState.someSelected}
|
||||||
|
onSelectAll={handleSelectAll}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Table body with status groups */}
|
||||||
|
<div className="flex-1 overflow-y-auto" role="rowgroup">
|
||||||
|
{statusGroups.map((group) => {
|
||||||
|
const isExpanded = !collapsedGroups.has(group.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
className="border-b border-border/30"
|
||||||
|
data-testid={`list-group-${group.id}`}
|
||||||
|
>
|
||||||
|
{/* Group header */}
|
||||||
|
<StatusGroupHeader
|
||||||
|
group={group}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onToggle={() => toggleGroup(group.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Group rows */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div role="rowgroup">
|
||||||
|
{group.features.map((feature) => (
|
||||||
|
<ListRow
|
||||||
|
key={feature.id}
|
||||||
|
feature={feature}
|
||||||
|
handlers={createHandlers(feature)}
|
||||||
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||||
|
pipelineConfig={pipelineConfig}
|
||||||
|
isSelected={selectedFeatureIds.has(feature.id)}
|
||||||
|
showCheckbox={isSelectionMode}
|
||||||
|
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||||
|
onClick={() => onRowClick?.(feature)}
|
||||||
|
blockingDependencies={getBlockingDeps(feature)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer with Add Feature button */}
|
||||||
|
{onAddFeature && (
|
||||||
|
<div className="border-t border-border px-4 py-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAddFeature}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
data-testid="list-view-add-feature"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Feature
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get all features from the columnFeaturesMap as a flat array
|
||||||
|
*/
|
||||||
|
export function getFlatFeatures(columnFeaturesMap: Record<string, Feature[]>): Feature[] {
|
||||||
|
return Object.values(columnFeaturesMap).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to count total features across all groups
|
||||||
|
*/
|
||||||
|
export function getTotalFeatureCount(columnFeaturesMap: Record<string, Feature[]>): number {
|
||||||
|
return Object.values(columnFeaturesMap).reduce((sum, features) => sum + features.length, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
import { memo, useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
MoreHorizontal,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
PlayCircle,
|
||||||
|
RotateCcw,
|
||||||
|
StopCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
FileText,
|
||||||
|
Eye,
|
||||||
|
Wand2,
|
||||||
|
Archive,
|
||||||
|
GitBranch,
|
||||||
|
GitFork,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import type { Feature } from '@/store/app-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action handler types for row actions
|
||||||
|
*/
|
||||||
|
export interface RowActionHandlers {
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onViewOutput?: () => void;
|
||||||
|
onVerify?: () => void;
|
||||||
|
onResume?: () => void;
|
||||||
|
onForceStop?: () => void;
|
||||||
|
onManualVerify?: () => void;
|
||||||
|
onFollowUp?: () => void;
|
||||||
|
onImplement?: () => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
onViewPlan?: () => void;
|
||||||
|
onApprovePlan?: () => void;
|
||||||
|
onSpawnTask?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RowActionsProps {
|
||||||
|
/** The feature for this row */
|
||||||
|
feature: Feature;
|
||||||
|
/** Action handlers */
|
||||||
|
handlers: RowActionHandlers;
|
||||||
|
/** Whether this feature is the current auto task (agent is running) */
|
||||||
|
isCurrentAutoTask?: boolean;
|
||||||
|
/** Whether the dropdown menu is open */
|
||||||
|
isOpen?: boolean;
|
||||||
|
/** Callback when the dropdown open state changes */
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
/** Additional className for custom styling */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MenuItem is a helper component for dropdown menu items with consistent styling
|
||||||
|
*/
|
||||||
|
const MenuItem = memo(function MenuItem({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
variant = 'default',
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: 'default' | 'destructive' | 'primary' | 'success' | 'warning';
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const variantClasses = {
|
||||||
|
default: '',
|
||||||
|
destructive: 'text-destructive focus:text-destructive focus:bg-destructive/10',
|
||||||
|
primary: 'text-primary focus:text-primary focus:bg-primary/10',
|
||||||
|
success:
|
||||||
|
'text-[var(--status-success)] focus:text-[var(--status-success)] focus:bg-[var(--status-success)]/10',
|
||||||
|
warning:
|
||||||
|
'text-[var(--status-waiting)] focus:text-[var(--status-waiting)] focus:bg-[var(--status-waiting)]/10',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('gap-2', variantClasses[variant])}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the primary action for quick access button based on feature status
|
||||||
|
*/
|
||||||
|
function getPrimaryAction(
|
||||||
|
feature: Feature,
|
||||||
|
handlers: RowActionHandlers,
|
||||||
|
isCurrentAutoTask: boolean
|
||||||
|
): {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: 'default' | 'destructive' | 'primary' | 'success' | 'warning';
|
||||||
|
} | null {
|
||||||
|
// Running task - force stop is primary
|
||||||
|
if (isCurrentAutoTask) {
|
||||||
|
if (handlers.onForceStop) {
|
||||||
|
return {
|
||||||
|
icon: StopCircle,
|
||||||
|
label: 'Stop',
|
||||||
|
onClick: handlers.onForceStop,
|
||||||
|
variant: 'destructive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backlog - implement is primary
|
||||||
|
if (feature.status === 'backlog' && handlers.onImplement) {
|
||||||
|
return {
|
||||||
|
icon: PlayCircle,
|
||||||
|
label: 'Make',
|
||||||
|
onClick: handlers.onImplement,
|
||||||
|
variant: 'primary',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// In progress with plan approval pending
|
||||||
|
if (
|
||||||
|
feature.status === 'in_progress' &&
|
||||||
|
feature.planSpec?.status === 'generated' &&
|
||||||
|
handlers.onApprovePlan
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
icon: FileText,
|
||||||
|
label: 'Approve',
|
||||||
|
onClick: handlers.onApprovePlan,
|
||||||
|
variant: 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// In progress - resume is primary
|
||||||
|
if (feature.status === 'in_progress' && handlers.onResume) {
|
||||||
|
return {
|
||||||
|
icon: RotateCcw,
|
||||||
|
label: 'Resume',
|
||||||
|
onClick: handlers.onResume,
|
||||||
|
variant: 'success',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waiting approval - verify is primary
|
||||||
|
if (feature.status === 'waiting_approval' && handlers.onManualVerify) {
|
||||||
|
return {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
label: 'Verify',
|
||||||
|
onClick: handlers.onManualVerify,
|
||||||
|
variant: 'success',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verified - complete is primary
|
||||||
|
if (feature.status === 'verified' && handlers.onComplete) {
|
||||||
|
return {
|
||||||
|
icon: Archive,
|
||||||
|
label: 'Complete',
|
||||||
|
onClick: handlers.onComplete,
|
||||||
|
variant: 'primary',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get secondary actions for inline display based on feature status
|
||||||
|
*/
|
||||||
|
function getSecondaryActions(
|
||||||
|
feature: Feature,
|
||||||
|
handlers: RowActionHandlers
|
||||||
|
): Array<{
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}> {
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
// Refine action for waiting_approval status
|
||||||
|
if (feature.status === 'waiting_approval' && handlers.onFollowUp) {
|
||||||
|
actions.push({
|
||||||
|
icon: Wand2,
|
||||||
|
label: 'Refine',
|
||||||
|
onClick: handlers.onFollowUp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RowActions provides an inline action menu for list view rows.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Quick access button for primary action (Make, Resume, Verify, etc.)
|
||||||
|
* - Dropdown menu with all available actions
|
||||||
|
* - Context-aware actions based on feature status
|
||||||
|
* - Support for running task actions (view logs, force stop)
|
||||||
|
* - Keyboard accessible (focus, Enter/Space to open)
|
||||||
|
*
|
||||||
|
* Actions by status:
|
||||||
|
* - Backlog: Edit, Delete, Make (implement), View Plan, Spawn Sub-Task
|
||||||
|
* - In Progress: View Logs, Resume, Approve Plan, Manual Verify, Edit, Spawn Sub-Task, Delete
|
||||||
|
* - Waiting Approval: Refine (inline secondary), Verify, View Logs, View PR, Edit, Spawn Sub-Task, Delete
|
||||||
|
* - Verified: View Logs, View PR, View Branch, Complete, Edit, Spawn Sub-Task, Delete
|
||||||
|
* - Running (auto task): View Logs, Approve Plan, Edit, Spawn Sub-Task, Force Stop
|
||||||
|
* - Pipeline statuses: View Logs, Edit, Spawn Sub-Task, Delete
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <RowActions
|
||||||
|
* feature={feature}
|
||||||
|
* handlers={{
|
||||||
|
* onEdit: () => handleEdit(feature.id),
|
||||||
|
* onDelete: () => handleDelete(feature.id),
|
||||||
|
* onImplement: () => handleImplement(feature.id),
|
||||||
|
* // ... other handlers
|
||||||
|
* }}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const RowActions = memo(function RowActions({
|
||||||
|
feature,
|
||||||
|
handlers,
|
||||||
|
isCurrentAutoTask = false,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
className,
|
||||||
|
}: RowActionsProps) {
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Use controlled or uncontrolled state
|
||||||
|
const open = isOpen ?? internalOpen;
|
||||||
|
const setOpen = (value: boolean) => {
|
||||||
|
if (onOpenChange) {
|
||||||
|
onOpenChange(value);
|
||||||
|
} else {
|
||||||
|
setInternalOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(newOpen: boolean) => {
|
||||||
|
setOpen(newOpen);
|
||||||
|
},
|
||||||
|
[setOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask);
|
||||||
|
const secondaryActions = getSecondaryActions(feature, handlers);
|
||||||
|
|
||||||
|
// Helper to close menu after action
|
||||||
|
const withClose = useCallback(
|
||||||
|
(handler: () => void) => () => {
|
||||||
|
setOpen(false);
|
||||||
|
handler();
|
||||||
|
},
|
||||||
|
[setOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex items-center gap-1', className)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`row-actions-${feature.id}`}
|
||||||
|
>
|
||||||
|
{/* Primary action quick button */}
|
||||||
|
{primaryAction && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7',
|
||||||
|
primaryAction.variant === 'destructive' &&
|
||||||
|
'hover:bg-destructive/10 hover:text-destructive',
|
||||||
|
primaryAction.variant === 'primary' && 'hover:bg-primary/10 hover:text-primary',
|
||||||
|
primaryAction.variant === 'success' &&
|
||||||
|
'hover:bg-[var(--status-success)]/10 hover:text-[var(--status-success)]',
|
||||||
|
primaryAction.variant === 'warning' &&
|
||||||
|
'hover:bg-[var(--status-waiting)]/10 hover:text-[var(--status-waiting)]'
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
primaryAction.onClick();
|
||||||
|
}}
|
||||||
|
title={primaryAction.label}
|
||||||
|
data-testid={`row-action-primary-${feature.id}`}
|
||||||
|
>
|
||||||
|
<primaryAction.icon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Secondary action buttons */}
|
||||||
|
{secondaryActions.map((action, index) => (
|
||||||
|
<Button
|
||||||
|
key={`secondary-action-${index}`}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn('h-7 w-7', 'text-muted-foreground', 'hover:bg-muted hover:text-foreground')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
action.onClick();
|
||||||
|
}}
|
||||||
|
title={action.label}
|
||||||
|
data-testid={`row-action-secondary-${feature.id}-${action.label.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
<action.icon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
|
data-testid={`row-actions-trigger-${feature.id}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
<span className="sr-only">Open actions menu</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
{/* Running task actions */}
|
||||||
|
{isCurrentAutoTask && (
|
||||||
|
<>
|
||||||
|
{handlers.onViewOutput && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="View Logs"
|
||||||
|
onClick={withClose(handlers.onViewOutput)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="Approve Plan"
|
||||||
|
onClick={withClose(handlers.onApprovePlan)}
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||||
|
{handlers.onSpawnTask && (
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Spawn Sub-Task"
|
||||||
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.onForceStop && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
icon={StopCircle}
|
||||||
|
label="Force Stop"
|
||||||
|
onClick={withClose(handlers.onForceStop)}
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Backlog actions */}
|
||||||
|
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
||||||
|
<>
|
||||||
|
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||||
|
{feature.planSpec?.content && handlers.onViewPlan && (
|
||||||
|
<MenuItem icon={Eye} label="View Plan" onClick={withClose(handlers.onViewPlan)} />
|
||||||
|
)}
|
||||||
|
{handlers.onImplement && (
|
||||||
|
<MenuItem
|
||||||
|
icon={PlayCircle}
|
||||||
|
label="Make"
|
||||||
|
onClick={withClose(handlers.onImplement)}
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.onSpawnTask && (
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Spawn Sub-Task"
|
||||||
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label="Delete"
|
||||||
|
onClick={withClose(handlers.onDelete)}
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* In Progress actions */}
|
||||||
|
{!isCurrentAutoTask && feature.status === 'in_progress' && (
|
||||||
|
<>
|
||||||
|
{handlers.onViewOutput && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="View Logs"
|
||||||
|
onClick={withClose(handlers.onViewOutput)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="Approve Plan"
|
||||||
|
onClick={withClose(handlers.onApprovePlan)}
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.skipTests && handlers.onManualVerify ? (
|
||||||
|
<MenuItem
|
||||||
|
icon={CheckCircle2}
|
||||||
|
label="Verify"
|
||||||
|
onClick={withClose(handlers.onManualVerify)}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
) : handlers.onResume ? (
|
||||||
|
<MenuItem
|
||||||
|
icon={RotateCcw}
|
||||||
|
label="Resume"
|
||||||
|
onClick={withClose(handlers.onResume)}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||||
|
{handlers.onSpawnTask && (
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Spawn Sub-Task"
|
||||||
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label="Delete"
|
||||||
|
onClick={withClose(handlers.onDelete)}
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Waiting Approval actions */}
|
||||||
|
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
|
||||||
|
<>
|
||||||
|
{handlers.onViewOutput && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="View Logs"
|
||||||
|
onClick={withClose(handlers.onViewOutput)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.onFollowUp && (
|
||||||
|
<MenuItem icon={Wand2} label="Refine" onClick={withClose(handlers.onFollowUp)} />
|
||||||
|
)}
|
||||||
|
{feature.prUrl && (
|
||||||
|
<MenuItem
|
||||||
|
icon={ExternalLink}
|
||||||
|
label="View PR"
|
||||||
|
onClick={withClose(() => window.open(feature.prUrl, '_blank'))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.onManualVerify && (
|
||||||
|
<MenuItem
|
||||||
|
icon={CheckCircle2}
|
||||||
|
label={feature.prUrl ? 'Verify' : 'Mark as Verified'}
|
||||||
|
onClick={withClose(handlers.onManualVerify)}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||||
|
{handlers.onSpawnTask && (
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Spawn Sub-Task"
|
||||||
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label="Delete"
|
||||||
|
onClick={withClose(handlers.onDelete)}
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verified actions */}
|
||||||
|
{!isCurrentAutoTask && feature.status === 'verified' && (
|
||||||
|
<>
|
||||||
|
{handlers.onViewOutput && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="View Logs"
|
||||||
|
onClick={withClose(handlers.onViewOutput)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.prUrl && (
|
||||||
|
<MenuItem
|
||||||
|
icon={ExternalLink}
|
||||||
|
label="View PR"
|
||||||
|
onClick={withClose(() => window.open(feature.prUrl, '_blank'))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.worktree && (
|
||||||
|
<MenuItem
|
||||||
|
icon={GitBranch}
|
||||||
|
label="View Branch"
|
||||||
|
onClick={withClose(() => {})}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.onComplete && (
|
||||||
|
<MenuItem
|
||||||
|
icon={Archive}
|
||||||
|
label="Complete"
|
||||||
|
onClick={withClose(handlers.onComplete)}
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||||
|
{handlers.onSpawnTask && (
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Spawn Sub-Task"
|
||||||
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label="Delete"
|
||||||
|
onClick={withClose(handlers.onDelete)}
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pipeline status actions (generic fallback) */}
|
||||||
|
{!isCurrentAutoTask && feature.status.startsWith('pipeline_') && (
|
||||||
|
<>
|
||||||
|
{handlers.onViewOutput && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="View Logs"
|
||||||
|
onClick={withClose(handlers.onViewOutput)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||||
|
{handlers.onSpawnTask && (
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Spawn Sub-Task"
|
||||||
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label="Delete"
|
||||||
|
onClick={withClose(handlers.onDelete)}
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create action handlers from common patterns
|
||||||
|
*/
|
||||||
|
export function createRowActionHandlers(
|
||||||
|
featureId: string,
|
||||||
|
actions: {
|
||||||
|
editFeature?: (id: string) => void;
|
||||||
|
deleteFeature?: (id: string) => void;
|
||||||
|
viewOutput?: (id: string) => void;
|
||||||
|
verifyFeature?: (id: string) => void;
|
||||||
|
resumeFeature?: (id: string) => void;
|
||||||
|
forceStop?: (id: string) => void;
|
||||||
|
manualVerify?: (id: string) => void;
|
||||||
|
followUp?: (id: string) => void;
|
||||||
|
implement?: (id: string) => void;
|
||||||
|
complete?: (id: string) => void;
|
||||||
|
viewPlan?: (id: string) => void;
|
||||||
|
approvePlan?: (id: string) => void;
|
||||||
|
spawnTask?: (id: string) => void;
|
||||||
|
}
|
||||||
|
): RowActionHandlers {
|
||||||
|
return {
|
||||||
|
onEdit: () => actions.editFeature?.(featureId),
|
||||||
|
onDelete: () => actions.deleteFeature?.(featureId),
|
||||||
|
onViewOutput: actions.viewOutput ? () => actions.viewOutput!(featureId) : undefined,
|
||||||
|
onVerify: actions.verifyFeature ? () => actions.verifyFeature!(featureId) : undefined,
|
||||||
|
onResume: actions.resumeFeature ? () => actions.resumeFeature!(featureId) : undefined,
|
||||||
|
onForceStop: actions.forceStop ? () => actions.forceStop!(featureId) : undefined,
|
||||||
|
onManualVerify: actions.manualVerify ? () => actions.manualVerify!(featureId) : undefined,
|
||||||
|
onFollowUp: actions.followUp ? () => actions.followUp!(featureId) : undefined,
|
||||||
|
onImplement: actions.implement ? () => actions.implement!(featureId) : undefined,
|
||||||
|
onComplete: actions.complete ? () => actions.complete!(featureId) : undefined,
|
||||||
|
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
|
||||||
|
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
|
||||||
|
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { memo, useMemo } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { COLUMNS, isPipelineStatus } from '../../constants';
|
||||||
|
import type { FeatureStatusWithPipeline, PipelineConfig } from '@automaker/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status display configuration
|
||||||
|
*/
|
||||||
|
interface StatusDisplay {
|
||||||
|
label: string;
|
||||||
|
colorClass: string;
|
||||||
|
bgClass: string;
|
||||||
|
borderClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base status display configurations using CSS variables
|
||||||
|
*/
|
||||||
|
const BASE_STATUS_DISPLAY: Record<string, StatusDisplay> = {
|
||||||
|
backlog: {
|
||||||
|
label: 'Backlog',
|
||||||
|
colorClass: 'text-[var(--status-backlog)]',
|
||||||
|
bgClass: 'bg-[var(--status-backlog)]/15',
|
||||||
|
borderClass: 'border-[var(--status-backlog)]/30',
|
||||||
|
},
|
||||||
|
in_progress: {
|
||||||
|
label: 'In Progress',
|
||||||
|
colorClass: 'text-[var(--status-in-progress)]',
|
||||||
|
bgClass: 'bg-[var(--status-in-progress)]/15',
|
||||||
|
borderClass: 'border-[var(--status-in-progress)]/30',
|
||||||
|
},
|
||||||
|
waiting_approval: {
|
||||||
|
label: 'Waiting Approval',
|
||||||
|
colorClass: 'text-[var(--status-waiting)]',
|
||||||
|
bgClass: 'bg-[var(--status-waiting)]/15',
|
||||||
|
borderClass: 'border-[var(--status-waiting)]/30',
|
||||||
|
},
|
||||||
|
verified: {
|
||||||
|
label: 'Verified',
|
||||||
|
colorClass: 'text-[var(--status-success)]',
|
||||||
|
bgClass: 'bg-[var(--status-success)]/15',
|
||||||
|
borderClass: 'border-[var(--status-success)]/30',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display configuration for a pipeline status
|
||||||
|
*/
|
||||||
|
function getPipelineStatusDisplay(
|
||||||
|
status: string,
|
||||||
|
pipelineConfig: PipelineConfig | null
|
||||||
|
): StatusDisplay | null {
|
||||||
|
if (!isPipelineStatus(status) || !pipelineConfig?.steps) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepId = status.replace('pipeline_', '');
|
||||||
|
const step = pipelineConfig.steps.find((s) => s.id === stepId);
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the color variable from the colorClass (e.g., "bg-[var(--status-in-progress)]")
|
||||||
|
// and use it for the badge styling
|
||||||
|
const colorVar = step.colorClass?.match(/var\(([^)]+)\)/)?.[1] || '--status-in-progress';
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: step.name || 'Pipeline Step',
|
||||||
|
colorClass: `text-[var(${colorVar})]`,
|
||||||
|
bgClass: `bg-[var(${colorVar})]/15`,
|
||||||
|
borderClass: `border-[var(${colorVar})]/30`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display configuration for a status
|
||||||
|
*/
|
||||||
|
function getStatusDisplay(
|
||||||
|
status: FeatureStatusWithPipeline,
|
||||||
|
pipelineConfig: PipelineConfig | null
|
||||||
|
): StatusDisplay {
|
||||||
|
// Check for pipeline status first
|
||||||
|
if (isPipelineStatus(status)) {
|
||||||
|
const pipelineDisplay = getPipelineStatusDisplay(status, pipelineConfig);
|
||||||
|
if (pipelineDisplay) {
|
||||||
|
return pipelineDisplay;
|
||||||
|
}
|
||||||
|
// Fallback for unknown pipeline status
|
||||||
|
return {
|
||||||
|
label: status.replace('pipeline_', '').replace(/_/g, ' '),
|
||||||
|
colorClass: 'text-[var(--status-in-progress)]',
|
||||||
|
bgClass: 'bg-[var(--status-in-progress)]/15',
|
||||||
|
borderClass: 'border-[var(--status-in-progress)]/30',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check base status
|
||||||
|
const baseDisplay = BASE_STATUS_DISPLAY[status];
|
||||||
|
if (baseDisplay) {
|
||||||
|
return baseDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find from COLUMNS constant
|
||||||
|
const column = COLUMNS.find((c) => c.id === status);
|
||||||
|
if (column) {
|
||||||
|
return {
|
||||||
|
label: column.title,
|
||||||
|
colorClass: 'text-muted-foreground',
|
||||||
|
bgClass: 'bg-muted/50',
|
||||||
|
borderClass: 'border-border/50',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for unknown status
|
||||||
|
return {
|
||||||
|
label: status.replace(/_/g, ' '),
|
||||||
|
colorClass: 'text-muted-foreground',
|
||||||
|
bgClass: 'bg-muted/50',
|
||||||
|
borderClass: 'border-border/50',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusBadgeProps {
|
||||||
|
/** The status to display */
|
||||||
|
status: FeatureStatusWithPipeline;
|
||||||
|
/** Optional pipeline configuration for custom pipeline steps */
|
||||||
|
pipelineConfig?: PipelineConfig | null;
|
||||||
|
/** Size variant for the badge */
|
||||||
|
size?: 'sm' | 'default' | 'lg';
|
||||||
|
/** Additional className for custom styling */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatusBadge displays a feature status as a colored chip/badge for use in the list view table.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Displays status with appropriate color based on status type
|
||||||
|
* - Supports base statuses (backlog, in_progress, waiting_approval, verified)
|
||||||
|
* - Supports pipeline statuses with custom colors from pipeline configuration
|
||||||
|
* - Size variants (sm, default, lg)
|
||||||
|
* - Uses CSS variables for consistent theming
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // Basic usage
|
||||||
|
* <StatusBadge status="backlog" />
|
||||||
|
*
|
||||||
|
* // With pipeline configuration
|
||||||
|
* <StatusBadge status="pipeline_review" pipelineConfig={pipelineConfig} />
|
||||||
|
*
|
||||||
|
* // Small size
|
||||||
|
* <StatusBadge status="verified" size="sm" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const StatusBadge = memo(function StatusBadge({
|
||||||
|
status,
|
||||||
|
pipelineConfig = null,
|
||||||
|
size = 'default',
|
||||||
|
className,
|
||||||
|
}: StatusBadgeProps) {
|
||||||
|
const display = useMemo(() => getStatusDisplay(status, pipelineConfig), [status, pipelineConfig]);
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-1.5 py-0.5 text-[10px]',
|
||||||
|
default: 'px-2 py-0.5 text-xs',
|
||||||
|
lg: 'px-2.5 py-1 text-sm',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full border font-medium whitespace-nowrap',
|
||||||
|
'transition-colors duration-200',
|
||||||
|
sizeClasses[size],
|
||||||
|
display.colorClass,
|
||||||
|
display.bgClass,
|
||||||
|
display.borderClass,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid={`status-badge-${status}`}
|
||||||
|
>
|
||||||
|
{display.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get the status label without rendering the badge
|
||||||
|
* Useful for sorting or filtering operations
|
||||||
|
*/
|
||||||
|
export function getStatusLabel(
|
||||||
|
status: FeatureStatusWithPipeline,
|
||||||
|
pipelineConfig: PipelineConfig | null = null
|
||||||
|
): string {
|
||||||
|
return getStatusDisplay(status, pipelineConfig).label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get the status order for sorting
|
||||||
|
* Returns a numeric value representing the status position in the workflow
|
||||||
|
*/
|
||||||
|
export function getStatusOrder(status: FeatureStatusWithPipeline): number {
|
||||||
|
const baseOrder: Record<string, number> = {
|
||||||
|
backlog: 0,
|
||||||
|
in_progress: 1,
|
||||||
|
waiting_approval: 2,
|
||||||
|
verified: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isPipelineStatus(status)) {
|
||||||
|
// Pipeline statuses come after in_progress but before waiting_approval
|
||||||
|
return 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseOrder[status] ?? 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { LayoutGrid, List } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type ViewMode = 'kanban' | 'list';
|
||||||
|
|
||||||
|
interface ViewToggleProps {
|
||||||
|
viewMode: ViewMode;
|
||||||
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A segmented control component for switching between kanban (grid) and list views.
|
||||||
|
* Uses icons to represent each view mode with clear visual feedback.
|
||||||
|
*/
|
||||||
|
export function ViewToggle({ viewMode, onViewModeChange, className }: ViewToggleProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 items-center rounded-md bg-muted p-[3px] border border-border',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="tablist"
|
||||||
|
aria-label="View mode"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'kanban'}
|
||||||
|
aria-label="Kanban view"
|
||||||
|
onClick={() => onViewModeChange('kanban')}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-all duration-200 cursor-pointer',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
viewMode === 'kanban'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-md'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
data-testid="view-toggle-kanban"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
<span className="sr-only sm:not-sr-only">Kanban</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'list'}
|
||||||
|
aria-label="List view"
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-all duration-200 cursor-pointer',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-md'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
data-testid="view-toggle-list"
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
<span className="sr-only sm:not-sr-only">List</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -304,22 +304,22 @@ export function AgentOutputModal({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
|
className="w-full h-full max-w-full max-h-full sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col"
|
||||||
data-testid="agent-output-modal"
|
data-testid="agent-output-modal"
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<div className="flex items-center justify-between pr-8">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||||
)}
|
)}
|
||||||
Agent Output
|
Agent Output
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
<div className="flex items-center gap-1 bg-muted rounded-lg p-1 overflow-x-auto">
|
||||||
{summary && (
|
{summary && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('summary')}
|
onClick={() => setViewMode('summary')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
|
||||||
effectiveViewMode === 'summary'
|
effectiveViewMode === 'summary'
|
||||||
? 'bg-primary/20 text-primary shadow-sm'
|
? 'bg-primary/20 text-primary shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
@@ -332,7 +332,7 @@ export function AgentOutputModal({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('parsed')}
|
onClick={() => setViewMode('parsed')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
|
||||||
effectiveViewMode === 'parsed'
|
effectiveViewMode === 'parsed'
|
||||||
? 'bg-primary/20 text-primary shadow-sm'
|
? 'bg-primary/20 text-primary shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
@@ -344,7 +344,7 @@ export function AgentOutputModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('changes')}
|
onClick={() => setViewMode('changes')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
|
||||||
effectiveViewMode === 'changes'
|
effectiveViewMode === 'changes'
|
||||||
? 'bg-primary/20 text-primary shadow-sm'
|
? 'bg-primary/20 text-primary shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
@@ -356,7 +356,7 @@ export function AgentOutputModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('raw')}
|
onClick={() => setViewMode('raw')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
|
||||||
effectiveViewMode === 'raw'
|
effectiveViewMode === 'raw'
|
||||||
? 'bg-primary/20 text-primary shadow-sm'
|
? 'bg-primary/20 text-primary shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
@@ -384,7 +384,7 @@ export function AgentOutputModal({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{effectiveViewMode === 'changes' ? (
|
{effectiveViewMode === 'changes' ? (
|
||||||
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
|
<div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||||
{projectPath ? (
|
{projectPath ? (
|
||||||
<GitDiffPanel
|
<GitDiffPanel
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
@@ -401,7 +401,7 @@ export function AgentOutputModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : effectiveViewMode === 'summary' && summary ? (
|
) : effectiveViewMode === 'summary' && summary ? (
|
||||||
<div className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 min-h-[400px] max-h-[60vh] scrollbar-visible">
|
<div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto bg-card border border-border/50 rounded-lg p-4 scrollbar-visible">
|
||||||
<Markdown>{summary}</Markdown>
|
<Markdown>{summary}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -409,7 +409,7 @@ export function AgentOutputModal({
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh] scrollbar-visible"
|
className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs scrollbar-visible"
|
||||||
>
|
>
|
||||||
{isLoading && !output ? (
|
{isLoading && !output ? (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { GitCommit, Loader2 } from 'lucide-react';
|
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -37,7 +38,9 @@ export function CommitWorktreeDialog({
|
|||||||
}: CommitWorktreeDialogProps) {
|
}: CommitWorktreeDialogProps) {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
|
||||||
|
|
||||||
const handleCommit = async () => {
|
const handleCommit = async () => {
|
||||||
if (!worktree || !message.trim()) return;
|
if (!worktree || !message.trim()) return;
|
||||||
@@ -77,11 +80,68 @@ export function CommitWorktreeDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && e.metaKey && !isLoading && message.trim()) {
|
// Prevent commit while loading or while AI is generating a message
|
||||||
|
if (e.key === 'Enter' && e.metaKey && !isLoading && !isGenerating && message.trim()) {
|
||||||
handleCommit();
|
handleCommit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Generate AI commit message when dialog opens (if enabled)
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && worktree) {
|
||||||
|
// Reset state
|
||||||
|
setMessage('');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Only generate AI commit message if enabled
|
||||||
|
if (!enableAiCommitMessages) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
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 {
|
||||||
|
// Don't show error toast, just log it and leave message empty
|
||||||
|
console.warn('Failed to generate commit message:', result.error);
|
||||||
|
setMessage('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
// Don't show error toast for generation failures
|
||||||
|
console.warn('Error generating commit message:', err);
|
||||||
|
setMessage('');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
generateMessage();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [open, worktree, enableAiCommitMessages]);
|
||||||
|
|
||||||
if (!worktree) return null;
|
if (!worktree) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,10 +166,20 @@ export function CommitWorktreeDialog({
|
|||||||
|
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="commit-message">Commit Message</Label>
|
<Label htmlFor="commit-message" className="flex items-center gap-2">
|
||||||
|
Commit Message
|
||||||
|
{isGenerating && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Sparkles className="w-3 h-3 animate-pulse" />
|
||||||
|
Generating...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="commit-message"
|
id="commit-message"
|
||||||
placeholder="Describe your changes..."
|
placeholder={
|
||||||
|
isGenerating ? 'Generating commit message...' : 'Describe your changes...'
|
||||||
|
}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setMessage(e.target.value);
|
setMessage(e.target.value);
|
||||||
@@ -118,6 +188,7 @@ export function CommitWorktreeDialog({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="min-h-[100px] font-mono text-sm"
|
className="min-h-[100px] font-mono text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
disabled={isGenerating}
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,10 +199,14 @@ export function CommitWorktreeDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isLoading || isGenerating}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCommit} disabled={isLoading || !message.trim()}>
|
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
|||||||
174
apps/ui/src/components/views/board-view/header-mobile-menu.tsx
Normal file
174
apps/ui/src/components/views/board-view/header-mobile-menu.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Menu, Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MobileUsageBar } from './mobile-usage-bar';
|
||||||
|
|
||||||
|
interface HeaderMobileMenuProps {
|
||||||
|
// Worktree panel visibility
|
||||||
|
isWorktreePanelVisible: boolean;
|
||||||
|
onWorktreePanelToggle: (visible: boolean) => void;
|
||||||
|
// Concurrency control
|
||||||
|
maxConcurrency: number;
|
||||||
|
runningAgentsCount: number;
|
||||||
|
onConcurrencyChange: (value: number) => void;
|
||||||
|
// Auto mode
|
||||||
|
isAutoModeRunning: boolean;
|
||||||
|
onAutoModeToggle: (enabled: boolean) => void;
|
||||||
|
onOpenAutoModeSettings: () => void;
|
||||||
|
// Plan button
|
||||||
|
onOpenPlanDialog: () => void;
|
||||||
|
// Usage bar visibility
|
||||||
|
showClaudeUsage: boolean;
|
||||||
|
showCodexUsage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeaderMobileMenu({
|
||||||
|
isWorktreePanelVisible,
|
||||||
|
onWorktreePanelToggle,
|
||||||
|
maxConcurrency,
|
||||||
|
runningAgentsCount,
|
||||||
|
onConcurrencyChange,
|
||||||
|
isAutoModeRunning,
|
||||||
|
onAutoModeToggle,
|
||||||
|
onOpenAutoModeSettings,
|
||||||
|
onOpenPlanDialog,
|
||||||
|
showClaudeUsage,
|
||||||
|
showCodexUsage,
|
||||||
|
}: HeaderMobileMenuProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
data-testid="header-mobile-menu-trigger"
|
||||||
|
>
|
||||||
|
<Menu className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-64">
|
||||||
|
{/* Usage Bar - show if either provider is authenticated */}
|
||||||
|
{(showClaudeUsage || showCodexUsage) && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||||
|
Usage
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||||
|
Controls
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{/* Auto Mode Toggle */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
|
||||||
|
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||||
|
data-testid="mobile-auto-mode-toggle-container"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-4',
|
||||||
|
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">Auto Mode</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="mobile-auto-mode-toggle"
|
||||||
|
checked={isAutoModeRunning}
|
||||||
|
onCheckedChange={onAutoModeToggle}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
data-testid="mobile-auto-mode-toggle"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenAutoModeSettings();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||||
|
title="Auto Mode Settings"
|
||||||
|
data-testid="mobile-auto-mode-settings-button"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{/* Worktrees Toggle */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
|
||||||
|
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
|
||||||
|
data-testid="mobile-worktrees-toggle-container"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Worktrees</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="mobile-worktrees-toggle"
|
||||||
|
checked={isWorktreePanelVisible}
|
||||||
|
onCheckedChange={onWorktreePanelToggle}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
data-testid="mobile-worktrees-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{/* Concurrency Control */}
|
||||||
|
<div className="px-2 py-2" data-testid="mobile-concurrency-control">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Max Agents</span>
|
||||||
|
<span
|
||||||
|
className="text-sm text-muted-foreground ml-auto"
|
||||||
|
data-testid="mobile-concurrency-value"
|
||||||
|
>
|
||||||
|
{runningAgentsCount}/{maxConcurrency}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[maxConcurrency]}
|
||||||
|
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
className="w-full"
|
||||||
|
data-testid="mobile-concurrency-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{/* Plan Button */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onOpenPlanDialog}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
data-testid="mobile-plan-button"
|
||||||
|
>
|
||||||
|
<Wand2 className="w-4 h-4" />
|
||||||
|
<span>Plan</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ export { useBoardBackground } from './use-board-background';
|
|||||||
export { useBoardPersistence } from './use-board-persistence';
|
export { useBoardPersistence } from './use-board-persistence';
|
||||||
export { useFollowUpState } from './use-follow-up-state';
|
export { useFollowUpState } from './use-follow-up-state';
|
||||||
export { useSelectionMode } from './use-selection-mode';
|
export { useSelectionMode } from './use-selection-mode';
|
||||||
|
export { useListViewState } from './use-list-view-state';
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { getJSON, setJSON } from '@/lib/storage';
|
||||||
|
import type { ViewMode } from '../components/view-toggle';
|
||||||
|
|
||||||
|
// Re-export ViewMode for convenience
|
||||||
|
export type { ViewMode };
|
||||||
|
|
||||||
|
/** Columns that can be sorted in the list view */
|
||||||
|
export type SortColumn = 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt';
|
||||||
|
|
||||||
|
/** Sort direction */
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
/** Sort configuration */
|
||||||
|
export interface SortConfig {
|
||||||
|
column: SortColumn;
|
||||||
|
direction: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persisted state for the list view */
|
||||||
|
interface ListViewPersistedState {
|
||||||
|
viewMode: ViewMode;
|
||||||
|
sortConfig: SortConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Storage key for list view preferences */
|
||||||
|
const STORAGE_KEY = 'automaker:list-view-state';
|
||||||
|
|
||||||
|
/** Default sort configuration */
|
||||||
|
const DEFAULT_SORT_CONFIG: SortConfig = {
|
||||||
|
column: 'createdAt',
|
||||||
|
direction: 'desc',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Default persisted state */
|
||||||
|
const DEFAULT_STATE: ListViewPersistedState = {
|
||||||
|
viewMode: 'kanban',
|
||||||
|
sortConfig: DEFAULT_SORT_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and returns a valid ViewMode, defaulting to 'kanban' if invalid
|
||||||
|
*/
|
||||||
|
function validateViewMode(value: unknown): ViewMode {
|
||||||
|
if (value === 'kanban' || value === 'list') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 'kanban';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and returns a valid SortColumn, defaulting to 'createdAt' if invalid
|
||||||
|
*/
|
||||||
|
function validateSortColumn(value: unknown): SortColumn {
|
||||||
|
const validColumns: SortColumn[] = [
|
||||||
|
'title',
|
||||||
|
'status',
|
||||||
|
'category',
|
||||||
|
'priority',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
];
|
||||||
|
if (typeof value === 'string' && validColumns.includes(value as SortColumn)) {
|
||||||
|
return value as SortColumn;
|
||||||
|
}
|
||||||
|
return 'createdAt';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and returns a valid SortDirection, defaulting to 'desc' if invalid
|
||||||
|
*/
|
||||||
|
function validateSortDirection(value: unknown): SortDirection {
|
||||||
|
if (value === 'asc' || value === 'desc') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load persisted state from localStorage with validation
|
||||||
|
*/
|
||||||
|
function loadPersistedState(): ListViewPersistedState {
|
||||||
|
const stored = getJSON<Partial<ListViewPersistedState>>(STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!stored) {
|
||||||
|
return DEFAULT_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewMode: validateViewMode(stored.viewMode),
|
||||||
|
sortConfig: {
|
||||||
|
column: validateSortColumn(stored.sortConfig?.column),
|
||||||
|
direction: validateSortDirection(stored.sortConfig?.direction),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save state to localStorage
|
||||||
|
*/
|
||||||
|
function savePersistedState(state: ListViewPersistedState): void {
|
||||||
|
setJSON(STORAGE_KEY, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseListViewStateReturn {
|
||||||
|
/** Current view mode (kanban or list) */
|
||||||
|
viewMode: ViewMode;
|
||||||
|
/** Set the view mode */
|
||||||
|
setViewMode: (mode: ViewMode) => void;
|
||||||
|
/** Toggle between kanban and list views */
|
||||||
|
toggleViewMode: () => void;
|
||||||
|
/** Whether the current view is list mode */
|
||||||
|
isListView: boolean;
|
||||||
|
/** Whether the current view is kanban mode */
|
||||||
|
isKanbanView: boolean;
|
||||||
|
/** Current sort configuration */
|
||||||
|
sortConfig: SortConfig;
|
||||||
|
/** Set the sort column (toggles direction if same column) */
|
||||||
|
setSortColumn: (column: SortColumn) => void;
|
||||||
|
/** Set the full sort configuration */
|
||||||
|
setSortConfig: (config: SortConfig) => void;
|
||||||
|
/** Reset sort to default */
|
||||||
|
resetSort: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing list view state including view mode, sorting, and localStorage persistence.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - View mode toggle between kanban and list views
|
||||||
|
* - Sort configuration with column and direction
|
||||||
|
* - Automatic persistence to localStorage
|
||||||
|
* - Validated state restoration on mount
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { viewMode, setViewMode, sortConfig, setSortColumn } = useListViewState();
|
||||||
|
*
|
||||||
|
* // Toggle view mode
|
||||||
|
* <ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||||
|
*
|
||||||
|
* // Sort by column (clicking same column toggles direction)
|
||||||
|
* <TableHeader onClick={() => setSortColumn('title')}>Title</TableHeader>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useListViewState(): UseListViewStateReturn {
|
||||||
|
// Initialize state from localStorage
|
||||||
|
const [viewMode, setViewModeState] = useState<ViewMode>(() => loadPersistedState().viewMode);
|
||||||
|
const [sortConfig, setSortConfigState] = useState<SortConfig>(
|
||||||
|
() => loadPersistedState().sortConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derived state
|
||||||
|
const isListView = viewMode === 'list';
|
||||||
|
const isKanbanView = viewMode === 'kanban';
|
||||||
|
|
||||||
|
// Persist state changes to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
savePersistedState({ viewMode, sortConfig });
|
||||||
|
}, [viewMode, sortConfig]);
|
||||||
|
|
||||||
|
// Set view mode
|
||||||
|
const setViewMode = useCallback((mode: ViewMode) => {
|
||||||
|
setViewModeState(mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toggle between kanban and list views
|
||||||
|
const toggleViewMode = useCallback(() => {
|
||||||
|
setViewModeState((prev) => (prev === 'kanban' ? 'list' : 'kanban'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set sort column - toggles direction if same column is clicked
|
||||||
|
const setSortColumn = useCallback((column: SortColumn) => {
|
||||||
|
setSortConfigState((prev) => {
|
||||||
|
if (prev.column === column) {
|
||||||
|
// Toggle direction if same column
|
||||||
|
return {
|
||||||
|
column,
|
||||||
|
direction: prev.direction === 'asc' ? 'desc' : 'asc',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// New column - default to descending for dates, ascending for others
|
||||||
|
const defaultDirection: SortDirection =
|
||||||
|
column === 'createdAt' || column === 'updatedAt' ? 'desc' : 'asc';
|
||||||
|
return { column, direction: defaultDirection };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set full sort configuration
|
||||||
|
const setSortConfig = useCallback((config: SortConfig) => {
|
||||||
|
setSortConfigState(config);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset sort to default
|
||||||
|
const resetSort = useCallback(() => {
|
||||||
|
setSortConfigState(DEFAULT_SORT_CONFIG);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
toggleViewMode,
|
||||||
|
isListView,
|
||||||
|
isKanbanView,
|
||||||
|
sortConfig,
|
||||||
|
setSortColumn,
|
||||||
|
setSortConfig,
|
||||||
|
resetSort,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
toggleViewMode,
|
||||||
|
isListView,
|
||||||
|
isKanbanView,
|
||||||
|
sortConfig,
|
||||||
|
setSortColumn,
|
||||||
|
setSortConfig,
|
||||||
|
resetSort,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-reac
|
|||||||
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||||
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||||
import type { PipelineConfig } from '@automaker/types';
|
import type { PipelineConfig } from '@automaker/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
sensors: any;
|
sensors: any;
|
||||||
collisionDetectionStrategy: (args: any) => any;
|
collisionDetectionStrategy: (args: any) => any;
|
||||||
@@ -57,6 +57,8 @@ interface KanbanBoardProps {
|
|||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
/** Whether the board is in read-only mode */
|
/** Whether the board is in read-only mode */
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
|
/** Additional className for custom styling (e.g., transition classes) */
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanBoard({
|
export function KanbanBoard({
|
||||||
@@ -95,6 +97,7 @@ export function KanbanBoard({
|
|||||||
onAiSuggest,
|
onAiSuggest,
|
||||||
isDragging = false,
|
isDragging = false,
|
||||||
isReadOnly = false,
|
isReadOnly = false,
|
||||||
|
className,
|
||||||
}: KanbanBoardProps) {
|
}: KanbanBoardProps) {
|
||||||
// Generate columns including pipeline steps
|
// Generate columns including pipeline steps
|
||||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||||
@@ -108,7 +111,14 @@ export function KanbanBoard({
|
|||||||
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-1 overflow-x-auto px-5 pt-4 pb-4 relative',
|
||||||
|
'transition-opacity duration-200',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={backgroundImageStyle}
|
||||||
|
>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={collisionDetectionStrategy}
|
collisionDetection={collisionDetectionStrategy}
|
||||||
|
|||||||
229
apps/ui/src/components/views/board-view/mobile-usage-bar.tsx
Normal file
229
apps/ui/src/components/views/board-view/mobile-usage-bar.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
|
|
||||||
|
interface MobileUsageBarProps {
|
||||||
|
showClaudeUsage: boolean;
|
||||||
|
showCodexUsage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get progress bar color based on percentage
|
||||||
|
function getProgressBarColor(percentage: number): string {
|
||||||
|
if (percentage >= 80) return 'bg-red-500';
|
||||||
|
if (percentage >= 50) return 'bg-yellow-500';
|
||||||
|
return 'bg-green-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual usage bar component
|
||||||
|
function UsageBar({
|
||||||
|
label,
|
||||||
|
percentage,
|
||||||
|
isStale,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
percentage: number;
|
||||||
|
isStale: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-1.5 first:mt-0">
|
||||||
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] font-mono font-bold',
|
||||||
|
percentage >= 80
|
||||||
|
? 'text-red-500'
|
||||||
|
: percentage >= 50
|
||||||
|
? 'text-yellow-500'
|
||||||
|
: 'text-green-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Math.round(percentage)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-1 w-full bg-muted-foreground/10 rounded-full overflow-hidden transition-opacity',
|
||||||
|
isStale && 'opacity-60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn('h-full transition-all duration-500', getProgressBarColor(percentage))}
|
||||||
|
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container for a provider's usage info
|
||||||
|
function UsageItem({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
isLoading,
|
||||||
|
onRefresh,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-2 py-2">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-sm font-semibold">{label}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRefresh();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||||
|
title="Refresh usage"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn('w-3.5 h-3.5 text-muted-foreground', isLoading && 'animate-spin')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="pl-6 space-y-2">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageBarProps) {
|
||||||
|
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||||
|
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
||||||
|
const [isClaudeLoading, setIsClaudeLoading] = useState(false);
|
||||||
|
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
||||||
|
|
||||||
|
// Check if data is stale (older than 2 minutes)
|
||||||
|
const isClaudeStale =
|
||||||
|
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
|
||||||
|
const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||||
|
|
||||||
|
const fetchClaudeUsage = useCallback(async () => {
|
||||||
|
setIsClaudeLoading(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.claude) return;
|
||||||
|
const data = await api.claude.getUsage();
|
||||||
|
if (!('error' in data)) {
|
||||||
|
setClaudeUsage(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently fail - usage display is optional
|
||||||
|
} finally {
|
||||||
|
setIsClaudeLoading(false);
|
||||||
|
}
|
||||||
|
}, [setClaudeUsage]);
|
||||||
|
|
||||||
|
const fetchCodexUsage = useCallback(async () => {
|
||||||
|
setIsCodexLoading(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.codex) return;
|
||||||
|
const data = await api.codex.getUsage();
|
||||||
|
if (!('error' in data)) {
|
||||||
|
setCodexUsage(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently fail - usage display is optional
|
||||||
|
} finally {
|
||||||
|
setIsCodexLoading(false);
|
||||||
|
}
|
||||||
|
}, [setCodexUsage]);
|
||||||
|
|
||||||
|
const getCodexWindowLabel = (durationMins: number) => {
|
||||||
|
if (durationMins < 60) return `${durationMins}m Window`;
|
||||||
|
if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`;
|
||||||
|
return `${Math.round(durationMins / 1440)}d Window`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-fetch on mount if data is stale
|
||||||
|
useEffect(() => {
|
||||||
|
if (showClaudeUsage && isClaudeStale) {
|
||||||
|
fetchClaudeUsage();
|
||||||
|
}
|
||||||
|
}, [showClaudeUsage, isClaudeStale, fetchClaudeUsage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showCodexUsage && isCodexStale) {
|
||||||
|
fetchCodexUsage();
|
||||||
|
}
|
||||||
|
}, [showCodexUsage, isCodexStale, fetchCodexUsage]);
|
||||||
|
|
||||||
|
// Don't render if there's nothing to show
|
||||||
|
if (!showClaudeUsage && !showCodexUsage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 py-1" data-testid="mobile-usage-bar">
|
||||||
|
{showClaudeUsage && (
|
||||||
|
<UsageItem
|
||||||
|
icon={AnthropicIcon}
|
||||||
|
label="Claude"
|
||||||
|
isLoading={isClaudeLoading}
|
||||||
|
onRefresh={fetchClaudeUsage}
|
||||||
|
>
|
||||||
|
{claudeUsage ? (
|
||||||
|
<>
|
||||||
|
<UsageBar
|
||||||
|
label="Session"
|
||||||
|
percentage={claudeUsage.sessionPercentage}
|
||||||
|
isStale={isClaudeStale}
|
||||||
|
/>
|
||||||
|
<UsageBar
|
||||||
|
label="Weekly"
|
||||||
|
percentage={claudeUsage.weeklyPercentage}
|
||||||
|
isStale={isClaudeStale}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-muted-foreground italic">Loading usage data...</p>
|
||||||
|
)}
|
||||||
|
</UsageItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCodexUsage && (
|
||||||
|
<UsageItem
|
||||||
|
icon={OpenAIIcon}
|
||||||
|
label="Codex"
|
||||||
|
isLoading={isCodexLoading}
|
||||||
|
onRefresh={fetchCodexUsage}
|
||||||
|
>
|
||||||
|
{codexUsage?.rateLimits ? (
|
||||||
|
<>
|
||||||
|
{codexUsage.rateLimits.primary && (
|
||||||
|
<UsageBar
|
||||||
|
label={getCodexWindowLabel(codexUsage.rateLimits.primary.windowDurationMins)}
|
||||||
|
percentage={codexUsage.rateLimits.primary.usedPercent}
|
||||||
|
isStale={isCodexStale}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{codexUsage.rateLimits.secondary && (
|
||||||
|
<UsageBar
|
||||||
|
label={getCodexWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins)}
|
||||||
|
percentage={codexUsage.rateLimits.secondary.usedPercent}
|
||||||
|
isStale={isCodexStale}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-muted-foreground italic">Loading usage data...</p>
|
||||||
|
)}
|
||||||
|
</UsageItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ export function PrioritySelector({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPrioritySelect(1)}
|
onClick={() => onPrioritySelect(1)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
|
||||||
selectedPriority === 1
|
selectedPriority === 1
|
||||||
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
|
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
|
||||||
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
||||||
@@ -30,7 +30,7 @@ export function PrioritySelector({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPrioritySelect(2)}
|
onClick={() => onPrioritySelect(2)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
|
||||||
selectedPriority === 2
|
selectedPriority === 2
|
||||||
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
|
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
|
||||||
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
||||||
@@ -43,7 +43,7 @@ export function PrioritySelector({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPrioritySelect(3)}
|
onClick={() => onPrioritySelect(3)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
|
||||||
selectedPriority === 3
|
selectedPriority === 3
|
||||||
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
|
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
|
||||||
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface BranchSwitchDropdownProps {
|
|||||||
branchFilter: string;
|
branchFilter: string;
|
||||||
isLoadingBranches: boolean;
|
isLoadingBranches: boolean;
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
|
/** When true, renders as a standalone button (not attached to another element) */
|
||||||
|
standalone?: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onFilterChange: (value: string) => void;
|
onFilterChange: (value: string) => void;
|
||||||
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
||||||
@@ -33,6 +35,7 @@ export function BranchSwitchDropdown({
|
|||||||
branchFilter,
|
branchFilter,
|
||||||
isLoadingBranches,
|
isLoadingBranches,
|
||||||
isSwitching,
|
isSwitching,
|
||||||
|
standalone = false,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
onSwitchBranch,
|
onSwitchBranch,
|
||||||
@@ -42,16 +45,18 @@ export function BranchSwitchDropdown({
|
|||||||
<DropdownMenu onOpenChange={onOpenChange}>
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={isSelected ? 'default' : 'outline'}
|
variant={standalone ? 'outline' : isSelected ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-7 w-7 p-0 rounded-none border-r-0',
|
'h-7 w-7 p-0',
|
||||||
isSelected && 'bg-primary text-primary-foreground',
|
!standalone && 'rounded-none border-r-0',
|
||||||
!isSelected && 'bg-secondary/50 hover:bg-secondary'
|
standalone && 'h-8 w-8 shrink-0',
|
||||||
|
!standalone && isSelected && 'bg-primary text-primary-foreground',
|
||||||
|
!standalone && !isSelected && 'bg-secondary/50 hover:bg-secondary'
|
||||||
)}
|
)}
|
||||||
title="Switch branch"
|
title="Switch branch"
|
||||||
>
|
>
|
||||||
<GitBranch className="w-3 h-3" />
|
<GitBranch className={standalone ? 'w-3.5 h-3.5' : 'w-3 h-3'} />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-64">
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { BranchSwitchDropdown } from './branch-switch-dropdown';
|
export { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||||
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||||
|
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
|
||||||
export { WorktreeTab } from './worktree-tab';
|
export { WorktreeTab } from './worktree-tab';
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ interface WorktreeActionsDropdownProps {
|
|||||||
isDevServerRunning: boolean;
|
isDevServerRunning: boolean;
|
||||||
devServerInfo?: DevServerInfo;
|
devServerInfo?: DevServerInfo;
|
||||||
gitRepoStatus: GitRepoStatus;
|
gitRepoStatus: GitRepoStatus;
|
||||||
|
/** When true, renders as a standalone button (not attached to another element) */
|
||||||
|
standalone?: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
@@ -71,6 +73,7 @@ export function WorktreeActionsDropdown({
|
|||||||
isDevServerRunning,
|
isDevServerRunning,
|
||||||
devServerInfo,
|
devServerInfo,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
|
standalone = false,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onPull,
|
onPull,
|
||||||
onPush,
|
onPush,
|
||||||
@@ -115,15 +118,17 @@ export function WorktreeActionsDropdown({
|
|||||||
<DropdownMenu onOpenChange={onOpenChange}>
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={isSelected ? 'default' : 'outline'}
|
variant={standalone ? 'outline' : isSelected ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-7 w-7 p-0 rounded-l-none',
|
'h-7 w-7 p-0',
|
||||||
isSelected && 'bg-primary text-primary-foreground',
|
!standalone && 'rounded-l-none',
|
||||||
!isSelected && 'bg-secondary/50 hover:bg-secondary'
|
standalone && 'h-8 w-8 shrink-0',
|
||||||
|
!standalone && isSelected && 'bg-primary text-primary-foreground',
|
||||||
|
!standalone && !isSelected && 'bg-secondary/50 hover:bg-secondary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="w-3 h-3" />
|
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-56">
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { GitBranch, ChevronDown, Loader2, CircleDot, Check } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { WorktreeInfo } from '../types';
|
||||||
|
|
||||||
|
interface WorktreeMobileDropdownProps {
|
||||||
|
worktrees: WorktreeInfo[];
|
||||||
|
isWorktreeSelected: (worktree: WorktreeInfo) => boolean;
|
||||||
|
hasRunningFeatures: (worktree: WorktreeInfo) => boolean;
|
||||||
|
isActivating: boolean;
|
||||||
|
branchCardCounts?: Record<string, number>;
|
||||||
|
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorktreeMobileDropdown({
|
||||||
|
worktrees,
|
||||||
|
isWorktreeSelected,
|
||||||
|
hasRunningFeatures,
|
||||||
|
isActivating,
|
||||||
|
branchCardCounts,
|
||||||
|
onSelectWorktree,
|
||||||
|
}: WorktreeMobileDropdownProps) {
|
||||||
|
// Find the currently selected worktree to display in the trigger
|
||||||
|
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
||||||
|
const displayBranch = selectedWorktree?.branch || 'Select branch';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3 gap-2 font-mono text-xs bg-secondary/50 hover:bg-secondary flex-1 min-w-0"
|
||||||
|
disabled={isActivating}
|
||||||
|
>
|
||||||
|
<GitBranch className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{displayBranch}</span>
|
||||||
|
{isActivating ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-64 max-h-80 overflow-y-auto">
|
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||||
|
Branches & Worktrees
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{worktrees.map((worktree) => {
|
||||||
|
const isSelected = isWorktreeSelected(worktree);
|
||||||
|
const isRunning = hasRunningFeatures(worktree);
|
||||||
|
const cardCount = branchCardCounts?.[worktree.branch];
|
||||||
|
const hasChanges = worktree.hasChanges;
|
||||||
|
const changedFilesCount = worktree.changedFilesCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={worktree.path}
|
||||||
|
onClick={() => onSelectWorktree(worktree)}
|
||||||
|
className={cn('flex items-center gap-2 cursor-pointer', isSelected && 'bg-accent')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{isSelected ? (
|
||||||
|
<Check className="w-3.5 h-3.5 shrink-0 text-primary" />
|
||||||
|
) : (
|
||||||
|
<div className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
{isRunning && <Loader2 className="w-3 h-3 animate-spin shrink-0" />}
|
||||||
|
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
|
||||||
|
{worktree.branch}
|
||||||
|
</span>
|
||||||
|
{worktree.isMain && (
|
||||||
|
<span className="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||||||
|
main
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{cardCount !== undefined && cardCount > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||||
|
{cardCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasChanges && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
|
||||||
|
'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
|
||||||
|
)}
|
||||||
|
title={`${changedFilesCount ?? 'Some'} uncommitted file${changedFilesCount !== 1 ? 's' : ''}`}
|
||||||
|
>
|
||||||
|
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||||
|
{changedFilesCount ?? '!'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
|||||||
import { cn, pathsEqual } from '@/lib/utils';
|
import { cn, pathsEqual } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||||
import {
|
import {
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
@@ -12,7 +13,12 @@ import {
|
|||||||
useWorktreeActions,
|
useWorktreeActions,
|
||||||
useRunningFeatures,
|
useRunningFeatures,
|
||||||
} from './hooks';
|
} from './hooks';
|
||||||
import { WorktreeTab } from './components';
|
import {
|
||||||
|
WorktreeTab,
|
||||||
|
WorktreeMobileDropdown,
|
||||||
|
WorktreeActionsDropdown,
|
||||||
|
BranchSwitchDropdown,
|
||||||
|
} from './components';
|
||||||
|
|
||||||
export function WorktreePanel({
|
export function WorktreePanel({
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -103,6 +109,8 @@ export function WorktreePanel({
|
|||||||
checkInitScript();
|
checkInitScript();
|
||||||
}, [projectPath]);
|
}, [projectPath]);
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Periodic interval check (5 seconds) to detect branch changes on disk
|
// Periodic interval check (5 seconds) to detect branch changes on disk
|
||||||
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
|
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -167,6 +175,105 @@ export function WorktreePanel({
|
|||||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||||
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
||||||
|
|
||||||
|
// Mobile view: single dropdown for all worktrees
|
||||||
|
if (isMobile) {
|
||||||
|
// Find the currently selected worktree for the actions menu
|
||||||
|
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w)) || mainWorktree;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||||
|
<WorktreeMobileDropdown
|
||||||
|
worktrees={worktrees}
|
||||||
|
isWorktreeSelected={isWorktreeSelected}
|
||||||
|
hasRunningFeatures={hasRunningFeatures}
|
||||||
|
isActivating={isActivating}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
|
onSelectWorktree={handleSelectWorktree}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Branch switch dropdown for the selected worktree */}
|
||||||
|
{selectedWorktree && (
|
||||||
|
<BranchSwitchDropdown
|
||||||
|
worktree={selectedWorktree}
|
||||||
|
isSelected={true}
|
||||||
|
standalone={true}
|
||||||
|
branches={branches}
|
||||||
|
filteredBranches={filteredBranches}
|
||||||
|
branchFilter={branchFilter}
|
||||||
|
isLoadingBranches={isLoadingBranches}
|
||||||
|
isSwitching={isSwitching}
|
||||||
|
onOpenChange={handleBranchDropdownOpenChange(selectedWorktree)}
|
||||||
|
onFilterChange={setBranchFilter}
|
||||||
|
onSwitchBranch={handleSwitchBranch}
|
||||||
|
onCreateBranch={onCreateBranch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions menu for the selected worktree */}
|
||||||
|
{selectedWorktree && (
|
||||||
|
<WorktreeActionsDropdown
|
||||||
|
worktree={selectedWorktree}
|
||||||
|
isSelected={true}
|
||||||
|
standalone={true}
|
||||||
|
aheadCount={aheadCount}
|
||||||
|
behindCount={behindCount}
|
||||||
|
isPulling={isPulling}
|
||||||
|
isPushing={isPushing}
|
||||||
|
isStartingDevServer={isStartingDevServer}
|
||||||
|
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
||||||
|
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||||
|
gitRepoStatus={gitRepoStatus}
|
||||||
|
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||||
|
onPull={handlePull}
|
||||||
|
onPush={handlePush}
|
||||||
|
onOpenInEditor={handleOpenInEditor}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onCreatePR={onCreatePR}
|
||||||
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onResolveConflicts={onResolveConflicts}
|
||||||
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
|
onStartDevServer={handleStartDevServer}
|
||||||
|
onStopDevServer={handleStopDevServer}
|
||||||
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
|
onRunInitScript={handleRunInitScript}
|
||||||
|
hasInitScript={hasInitScript}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{useWorktreesEnabled && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground shrink-0"
|
||||||
|
onClick={onCreateWorktree}
|
||||||
|
title="Create new worktree"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground shrink-0"
|
||||||
|
onClick={async () => {
|
||||||
|
const removedWorktrees = await fetchWorktrees();
|
||||||
|
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||||
|
onRemovedWorktrees(removedWorktrees);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
title="Refresh worktrees"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop view: full tabs layout
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useNodesState,
|
useNodesState,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
|
useReactFlow,
|
||||||
SelectionMode,
|
SelectionMode,
|
||||||
ConnectionMode,
|
ConnectionMode,
|
||||||
Node,
|
Node,
|
||||||
@@ -244,6 +245,82 @@ function GraphCanvasInner({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get fitView from React Flow for orientation change handling
|
||||||
|
const { fitView } = useReactFlow();
|
||||||
|
|
||||||
|
// Handle orientation changes on mobile devices
|
||||||
|
// When rotating from landscape to portrait, the view may incorrectly zoom in
|
||||||
|
// This effect listens for orientation changes and calls fitView to correct the viewport
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// Track the previous orientation to detect changes
|
||||||
|
let previousWidth = window.innerWidth;
|
||||||
|
let previousHeight = window.innerHeight;
|
||||||
|
|
||||||
|
// Track timeout IDs for cleanup
|
||||||
|
let orientationTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let resizeTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const handleOrientationChange = () => {
|
||||||
|
// Clear any pending timeout
|
||||||
|
if (orientationTimeoutId) {
|
||||||
|
clearTimeout(orientationTimeoutId);
|
||||||
|
}
|
||||||
|
// Small delay to allow the browser to complete the orientation change
|
||||||
|
orientationTimeoutId = setTimeout(() => {
|
||||||
|
fitView({ padding: 0.2, duration: 300 });
|
||||||
|
orientationTimeoutId = null;
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
const currentWidth = window.innerWidth;
|
||||||
|
const currentHeight = window.innerHeight;
|
||||||
|
|
||||||
|
// Detect orientation change by checking if width and height swapped significantly
|
||||||
|
// This happens when device rotates between portrait and landscape
|
||||||
|
const widthDiff = Math.abs(currentWidth - previousHeight);
|
||||||
|
const heightDiff = Math.abs(currentHeight - previousWidth);
|
||||||
|
|
||||||
|
// If the dimensions are close to being swapped (within 100px tolerance)
|
||||||
|
// it's likely an orientation change
|
||||||
|
const isOrientationChange = widthDiff < 100 && heightDiff < 100;
|
||||||
|
|
||||||
|
if (isOrientationChange) {
|
||||||
|
// Clear any pending timeout
|
||||||
|
if (resizeTimeoutId) {
|
||||||
|
clearTimeout(resizeTimeoutId);
|
||||||
|
}
|
||||||
|
// Delay fitView to allow browser to complete the layout
|
||||||
|
resizeTimeoutId = setTimeout(() => {
|
||||||
|
fitView({ padding: 0.2, duration: 300 });
|
||||||
|
resizeTimeoutId = null;
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousWidth = currentWidth;
|
||||||
|
previousHeight = currentHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for orientation change event (mobile specific)
|
||||||
|
window.addEventListener('orientationchange', handleOrientationChange);
|
||||||
|
// Also listen for resize as a fallback (some browsers don't fire orientationchange)
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('orientationchange', handleOrientationChange);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
// Clear any pending timeouts
|
||||||
|
if (orientationTimeoutId) {
|
||||||
|
clearTimeout(orientationTimeoutId);
|
||||||
|
}
|
||||||
|
if (resizeTimeoutId) {
|
||||||
|
clearTimeout(resizeTimeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fitView]);
|
||||||
|
|
||||||
// MiniMap node color based on status
|
// MiniMap node color based on status
|
||||||
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
|
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
|
||||||
const data = node.data as TaskNodeData | undefined;
|
const data = node.data as TaskNodeData | undefined;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useSearch } from '@tanstack/react-router';
|
import { useSearch } from '@tanstack/react-router';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
@@ -30,6 +30,9 @@ import { PromptCustomizationSection } from './settings-view/prompts';
|
|||||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||||
import type { Project as ElectronProject } from '@/lib/electron';
|
import type { Project as ElectronProject } from '@/lib/electron';
|
||||||
|
|
||||||
|
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||||
|
const LG_BREAKPOINT = 1024;
|
||||||
|
|
||||||
export function SettingsView() {
|
export function SettingsView() {
|
||||||
const {
|
const {
|
||||||
theme,
|
theme,
|
||||||
@@ -41,6 +44,8 @@ export function SettingsView() {
|
|||||||
setEnableDependencyBlocking,
|
setEnableDependencyBlocking,
|
||||||
skipVerificationInAutoMode,
|
skipVerificationInAutoMode,
|
||||||
setSkipVerificationInAutoMode,
|
setSkipVerificationInAutoMode,
|
||||||
|
enableAiCommitMessages,
|
||||||
|
setEnableAiCommitMessages,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
setUseWorktrees,
|
setUseWorktrees,
|
||||||
muteDoneSound,
|
muteDoneSound,
|
||||||
@@ -108,6 +113,33 @@ export function SettingsView() {
|
|||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||||
|
|
||||||
|
// Mobile navigation state - default to showing on desktop, hidden on mobile
|
||||||
|
const [showNavigation, setShowNavigation] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.innerWidth >= LG_BREAKPOINT;
|
||||||
|
}
|
||||||
|
return true; // Default to showing on SSR
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-close navigation on mobile when a section is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth < LG_BREAKPOINT) {
|
||||||
|
setShowNavigation(false);
|
||||||
|
}
|
||||||
|
}, [activeView]);
|
||||||
|
|
||||||
|
// Handle window resize to show/hide navigation appropriately
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (window.innerWidth >= LG_BREAKPOINT) {
|
||||||
|
setShowNavigation(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Render the active section based on current view
|
// Render the active section based on current view
|
||||||
const renderActiveSection = () => {
|
const renderActiveSection = () => {
|
||||||
switch (activeView) {
|
switch (activeView) {
|
||||||
@@ -159,12 +191,14 @@ export function SettingsView() {
|
|||||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||||
defaultPlanningMode={defaultPlanningMode}
|
defaultPlanningMode={defaultPlanningMode}
|
||||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||||
|
enableAiCommitMessages={enableAiCommitMessages}
|
||||||
defaultFeatureModel={defaultFeatureModel}
|
defaultFeatureModel={defaultFeatureModel}
|
||||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||||
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
||||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||||
|
onEnableAiCommitMessagesChange={setEnableAiCommitMessages}
|
||||||
onDefaultFeatureModelChange={setDefaultFeatureModel}
|
onDefaultFeatureModelChange={setDefaultFeatureModel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -196,20 +230,25 @@ export function SettingsView() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
|
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<SettingsHeader />
|
<SettingsHeader
|
||||||
|
showNavigation={showNavigation}
|
||||||
|
onToggleNavigation={() => setShowNavigation(!showNavigation)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Content Area with Sidebar */}
|
{/* Content Area with Sidebar */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Side Navigation - No longer scrolls, just switches views */}
|
{/* Side Navigation - Overlay on mobile, sidebar on desktop */}
|
||||||
<SettingsNavigation
|
<SettingsNavigation
|
||||||
navItems={NAV_ITEMS}
|
navItems={NAV_ITEMS}
|
||||||
activeSection={activeView}
|
activeSection={activeView}
|
||||||
currentProject={currentProject}
|
currentProject={currentProject}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
|
isOpen={showNavigation}
|
||||||
|
onClose={() => setShowNavigation(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content Panel - Shows only the active section */}
|
{/* Content Panel - Shows only the active section */}
|
||||||
<div className="flex-1 overflow-y-auto p-8">
|
<div className="flex-1 overflow-y-auto p-4 lg:p-8">
|
||||||
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
|
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,157 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const ERROR_NO_API = 'Claude usage API not available';
|
||||||
|
const CLAUDE_USAGE_TITLE = 'Claude Usage';
|
||||||
|
const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.';
|
||||||
|
const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.';
|
||||||
|
const CLAUDE_LOGIN_COMMAND = 'claude login';
|
||||||
|
const CLAUDE_NO_USAGE_MESSAGE =
|
||||||
|
'Usage limits are not available yet. Try refreshing if this persists.';
|
||||||
|
const UPDATED_LABEL = 'Updated';
|
||||||
|
const CLAUDE_FETCH_ERROR = 'Failed to fetch usage';
|
||||||
|
const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage';
|
||||||
|
const WARNING_THRESHOLD = 75;
|
||||||
|
const CAUTION_THRESHOLD = 50;
|
||||||
|
const MAX_PERCENTAGE = 100;
|
||||||
|
const REFRESH_INTERVAL_MS = 60_000;
|
||||||
|
const STALE_THRESHOLD_MS = 2 * 60_000;
|
||||||
|
// Using purple/indigo for Claude branding
|
||||||
|
const USAGE_COLOR_CRITICAL = 'bg-red-500';
|
||||||
|
const USAGE_COLOR_WARNING = 'bg-amber-500';
|
||||||
|
const USAGE_COLOR_OK = 'bg-indigo-500';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate color class for a usage percentage
|
||||||
|
*/
|
||||||
|
function getUsageColor(percentage: number): string {
|
||||||
|
if (percentage >= WARNING_THRESHOLD) {
|
||||||
|
return USAGE_COLOR_CRITICAL;
|
||||||
|
}
|
||||||
|
if (percentage >= CAUTION_THRESHOLD) {
|
||||||
|
return USAGE_COLOR_WARNING;
|
||||||
|
}
|
||||||
|
return USAGE_COLOR_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual usage card displaying a usage metric with progress bar
|
||||||
|
*/
|
||||||
|
function UsageCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
percentage,
|
||||||
|
resetText,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
percentage: number;
|
||||||
|
resetText?: string;
|
||||||
|
}) {
|
||||||
|
const safePercentage = Math.min(Math.max(percentage, 0), MAX_PERCENTAGE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">{title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-foreground">{Math.round(safePercentage)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-2 w-full rounded-full bg-secondary/60">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all duration-300',
|
||||||
|
getUsageColor(safePercentage)
|
||||||
|
)}
|
||||||
|
style={{ width: `${safePercentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{resetText && <p className="mt-2 text-xs text-muted-foreground">{resetText}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ClaudeUsageSection() {
|
export function ClaudeUsageSection() {
|
||||||
|
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
|
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const canFetchUsage = !!claudeAuthStatus?.authenticated;
|
||||||
|
// If we have usage data, we can show it even if auth status is unsure
|
||||||
|
const hasUsage = !!claudeUsage;
|
||||||
|
|
||||||
|
const lastUpdatedLabel = claudeUsageLastUpdated
|
||||||
|
? new Date(claudeUsageLastUpdated).toLocaleString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const showAuthWarning =
|
||||||
|
(!canFetchUsage && !hasUsage && !isLoading) ||
|
||||||
|
(error && error.includes('Authentication required'));
|
||||||
|
|
||||||
|
const isStale =
|
||||||
|
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
|
||||||
|
|
||||||
|
const fetchUsage = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.claude) {
|
||||||
|
setError(ERROR_NO_API);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.claude.getUsage();
|
||||||
|
|
||||||
|
if ('error' in result) {
|
||||||
|
// Check for auth errors specifically
|
||||||
|
if (
|
||||||
|
result.message?.includes('Authentication required') ||
|
||||||
|
result.error?.includes('Authentication required')
|
||||||
|
) {
|
||||||
|
// We'll show the auth warning UI instead of a generic error
|
||||||
|
} else {
|
||||||
|
setError(result.message || result.error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setClaudeUsage(result);
|
||||||
|
} catch (fetchError) {
|
||||||
|
const message = fetchError instanceof Error ? fetchError.message : CLAUDE_FETCH_ERROR;
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [setClaudeUsage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initial fetch if authenticated and stale
|
||||||
|
// Compute staleness inside effect to avoid re-running when Date.now() changes
|
||||||
|
const isDataStale =
|
||||||
|
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
|
||||||
|
if (canFetchUsage && isDataStale) {
|
||||||
|
void fetchUsage();
|
||||||
|
}
|
||||||
|
}, [fetchUsage, canFetchUsage, claudeUsageLastUpdated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canFetchUsage) return undefined;
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
void fetchUsage();
|
||||||
|
}, REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [fetchUsage, canFetchUsage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -12,30 +163,73 @@ export function ClaudeUsageSection() {
|
|||||||
>
|
>
|
||||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-indigo-500/20 to-indigo-600/10 flex items-center justify-center border border-indigo-500/20">
|
||||||
<div className="w-5 h-5 rounded-full bg-green-500/50" />
|
<div className="w-5 h-5 rounded-full bg-indigo-500/50" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||||
Claude Usage Tracking
|
{CLAUDE_USAGE_TITLE}
|
||||||
</h2>
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={fetchUsage}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
|
||||||
|
data-testid="refresh-claude-usage"
|
||||||
|
title={CLAUDE_REFRESH_LABEL}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
|
||||||
Track your Claude Code usage limits. Uses the Claude CLI for data.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
{/* Info about CLI requirement */}
|
<div className="p-6 space-y-4">
|
||||||
<div className="rounded-lg bg-secondary/30 p-3 text-xs text-muted-foreground space-y-2 border border-border/50">
|
{showAuthWarning && (
|
||||||
<p>Usage tracking requires Claude Code CLI to be installed and authenticated:</p>
|
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-1">
|
<AlertCircle className="w-5 h-5 text-amber-500 mt-0.5" />
|
||||||
<li>Install Claude Code CLI if not already installed</li>
|
<div className="text-sm text-amber-400">
|
||||||
<li>
|
{CLAUDE_AUTH_WARNING} Run <span className="font-mono">{CLAUDE_LOGIN_COMMAND}</span>.
|
||||||
Run <code className="font-mono bg-muted px-1 rounded">claude login</code> to
|
</div>
|
||||||
authenticate
|
</div>
|
||||||
</li>
|
)}
|
||||||
<li>Usage data will be fetched automatically every ~minute</li>
|
|
||||||
</ol>
|
{error && !showAuthWarning && (
|
||||||
</div>
|
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
|
||||||
|
<div className="text-sm text-red-400">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasUsage && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<UsageCard
|
||||||
|
title="Session Limit"
|
||||||
|
subtitle="5-hour rolling window"
|
||||||
|
percentage={claudeUsage.sessionPercentage}
|
||||||
|
resetText={claudeUsage.sessionResetText}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UsageCard
|
||||||
|
title="Weekly Limit"
|
||||||
|
subtitle="Resets every Thursday"
|
||||||
|
percentage={claudeUsage.weeklyPercentage}
|
||||||
|
resetText={claudeUsage.weeklyResetText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasUsage && !error && !showAuthWarning && !isLoading && (
|
||||||
|
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
||||||
|
{CLAUDE_NO_USAGE_MESSAGE}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lastUpdatedLabel && (
|
||||||
|
<div className="text-[10px] text-muted-foreground text-right">
|
||||||
|
{UPDATED_LABEL} {lastUpdatedLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Settings } from 'lucide-react';
|
import { Settings, PanelLeft, PanelLeftClose } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
interface SettingsHeaderProps {
|
interface SettingsHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
showNavigation?: boolean;
|
||||||
|
onToggleNavigation?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsHeader({
|
export function SettingsHeader({
|
||||||
title = 'Settings',
|
title = 'Settings',
|
||||||
description = 'Configure your API keys and preferences',
|
description = 'Configure your API keys and preferences',
|
||||||
|
showNavigation,
|
||||||
|
onToggleNavigation,
|
||||||
}: SettingsHeaderProps) {
|
}: SettingsHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -18,21 +23,39 @@ export function SettingsHeader({
|
|||||||
'bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl'
|
'bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="px-8 py-6">
|
<div className="px-4 py-4 lg:px-8 lg:py-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3 lg:gap-4">
|
||||||
|
{/* Mobile menu toggle button - only visible on mobile */}
|
||||||
|
{onToggleNavigation && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleNavigation}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden"
|
||||||
|
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
|
||||||
|
>
|
||||||
|
{showNavigation ? (
|
||||||
|
<PanelLeftClose className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<PanelLeft className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-12 h-12 rounded-2xl flex items-center justify-center',
|
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
|
||||||
'bg-gradient-to-br from-brand-500 to-brand-600',
|
'bg-gradient-to-br from-brand-500 to-brand-600',
|
||||||
'shadow-lg shadow-brand-500/25',
|
'shadow-lg shadow-brand-500/25',
|
||||||
'ring-1 ring-white/10'
|
'ring-1 ring-white/10'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Settings className="w-6 h-6 text-white" />
|
<Settings className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground tracking-tight">{title}</h1>
|
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
|
||||||
<p className="text-sm text-muted-foreground/80 mt-0.5">{description}</p>
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronRight, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import type { NavigationItem, NavigationGroup } from '../config/navigation';
|
import type { NavigationItem, NavigationGroup } from '../config/navigation';
|
||||||
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation';
|
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation';
|
||||||
@@ -13,6 +14,8 @@ interface SettingsNavigationProps {
|
|||||||
activeSection: SettingsViewId;
|
activeSection: SettingsViewId;
|
||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
onNavigate: (sectionId: SettingsViewId) => void;
|
onNavigate: (sectionId: SettingsViewId) => void;
|
||||||
|
isOpen?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavButton({
|
function NavButton({
|
||||||
@@ -167,75 +170,116 @@ export function SettingsNavigation({
|
|||||||
activeSection,
|
activeSection,
|
||||||
currentProject,
|
currentProject,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
isOpen = true,
|
||||||
|
onClose,
|
||||||
}: SettingsNavigationProps) {
|
}: SettingsNavigationProps) {
|
||||||
|
// On mobile, only show when isOpen is true
|
||||||
|
// On desktop (lg+), always show regardless of isOpen
|
||||||
|
// The desktop visibility is handled by CSS, but we need to render on mobile only when open
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<>
|
||||||
className={cn(
|
{/* Mobile backdrop overlay - only shown when isOpen is true on mobile */}
|
||||||
'hidden lg:block w-64 shrink-0 overflow-y-auto',
|
{isOpen && (
|
||||||
'border-r border-border/50',
|
<div
|
||||||
'bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl'
|
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="settings-nav-backdrop"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<div className="sticky top-0 p-4 space-y-1">
|
|
||||||
{/* Global Settings Groups */}
|
|
||||||
{GLOBAL_NAV_GROUPS.map((group, groupIndex) => (
|
|
||||||
<div key={group.label}>
|
|
||||||
{/* Group divider (except for first group) */}
|
|
||||||
{groupIndex > 0 && <div className="my-3 border-t border-border/50" />}
|
|
||||||
|
|
||||||
{/* Group Label */}
|
{/* Navigation sidebar */}
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
<nav
|
||||||
{group.label}
|
className={cn(
|
||||||
|
// Mobile: fixed position overlay with slide transition
|
||||||
|
'fixed inset-y-0 left-0 w-72 z-30',
|
||||||
|
'transition-transform duration-200 ease-out',
|
||||||
|
// Hide on mobile when closed, show when open
|
||||||
|
isOpen ? 'translate-x-0' : '-translate-x-full',
|
||||||
|
// Desktop: relative position in layout, always visible
|
||||||
|
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
|
||||||
|
'shrink-0 overflow-y-auto',
|
||||||
|
'border-r border-border/50',
|
||||||
|
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl',
|
||||||
|
// Desktop background
|
||||||
|
'lg:from-card/80 lg:via-card/60 lg:to-card/40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Mobile close button */}
|
||||||
|
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border/50">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Navigation</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Close navigation menu"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky top-0 p-4 space-y-1">
|
||||||
|
{/* Global Settings Groups */}
|
||||||
|
{GLOBAL_NAV_GROUPS.map((group, groupIndex) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
{/* Group divider (except for first group) */}
|
||||||
|
{groupIndex > 0 && <div className="my-3 border-t border-border/50" />}
|
||||||
|
|
||||||
|
{/* Group Label */}
|
||||||
|
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group Items */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.items.map((item) =>
|
||||||
|
item.subItems ? (
|
||||||
|
<NavItemWithSubItems
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
activeSection={activeSection}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<NavButton
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isActive={activeSection === item.id}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Group Items */}
|
{/* Project Settings - only show when a project is selected */}
|
||||||
<div className="space-y-1">
|
{currentProject && (
|
||||||
{group.items.map((item) =>
|
<>
|
||||||
item.subItems ? (
|
{/* Divider */}
|
||||||
<NavItemWithSubItems
|
<div className="my-3 border-t border-border/50" />
|
||||||
key={item.id}
|
|
||||||
item={item}
|
{/* Project Settings Label */}
|
||||||
activeSection={activeSection}
|
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
||||||
onNavigate={onNavigate}
|
Project Settings
|
||||||
/>
|
</div>
|
||||||
) : (
|
|
||||||
|
{/* Project Settings Items */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{PROJECT_NAV_ITEMS.map((item) => (
|
||||||
<NavButton
|
<NavButton
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
isActive={activeSection === item.id}
|
isActive={activeSection === item.id}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
))}
|
</div>
|
||||||
|
</nav>
|
||||||
{/* Project Settings - only show when a project is selected */}
|
</>
|
||||||
{currentProject && (
|
|
||||||
<>
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="my-3 border-t border-border/50" />
|
|
||||||
|
|
||||||
{/* Project Settings Label */}
|
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
|
||||||
Project Settings
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Project Settings Items */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{PROJECT_NAV_ITEMS.map((item) => (
|
|
||||||
<NavButton
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
isActive={activeSection === item.id}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ScrollText,
|
ScrollText,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
FastForward,
|
FastForward,
|
||||||
|
Sparkles,
|
||||||
Cpu,
|
Cpu,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -31,12 +32,14 @@ interface FeatureDefaultsSectionProps {
|
|||||||
skipVerificationInAutoMode: boolean;
|
skipVerificationInAutoMode: boolean;
|
||||||
defaultPlanningMode: PlanningMode;
|
defaultPlanningMode: PlanningMode;
|
||||||
defaultRequirePlanApproval: boolean;
|
defaultRequirePlanApproval: boolean;
|
||||||
|
enableAiCommitMessages: boolean;
|
||||||
defaultFeatureModel: PhaseModelEntry;
|
defaultFeatureModel: PhaseModelEntry;
|
||||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||||
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
||||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||||
|
onEnableAiCommitMessagesChange: (value: boolean) => void;
|
||||||
onDefaultFeatureModelChange: (value: PhaseModelEntry) => void;
|
onDefaultFeatureModelChange: (value: PhaseModelEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +49,14 @@ export function FeatureDefaultsSection({
|
|||||||
skipVerificationInAutoMode,
|
skipVerificationInAutoMode,
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
|
enableAiCommitMessages,
|
||||||
defaultFeatureModel,
|
defaultFeatureModel,
|
||||||
onDefaultSkipTestsChange,
|
onDefaultSkipTestsChange,
|
||||||
onEnableDependencyBlockingChange,
|
onEnableDependencyBlockingChange,
|
||||||
onSkipVerificationInAutoModeChange,
|
onSkipVerificationInAutoModeChange,
|
||||||
onDefaultPlanningModeChange,
|
onDefaultPlanningModeChange,
|
||||||
onDefaultRequirePlanApprovalChange,
|
onDefaultRequirePlanApprovalChange,
|
||||||
|
onEnableAiCommitMessagesChange,
|
||||||
onDefaultFeatureModelChange,
|
onDefaultFeatureModelChange,
|
||||||
}: FeatureDefaultsSectionProps) {
|
}: FeatureDefaultsSectionProps) {
|
||||||
return (
|
return (
|
||||||
@@ -281,6 +286,34 @@ export function FeatureDefaultsSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
|
{/* AI Commit Messages Setting */}
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
<Checkbox
|
||||||
|
id="enable-ai-commit-messages"
|
||||||
|
checked={enableAiCommitMessages}
|
||||||
|
onCheckedChange={(checked) => onEnableAiCommitMessagesChange(checked === true)}
|
||||||
|
className="mt-1"
|
||||||
|
data-testid="enable-ai-commit-messages-checkbox"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="enable-ai-commit-messages"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 text-brand-500" />
|
||||||
|
Generate AI commit messages
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
When enabled, opening the commit dialog will automatically generate a commit message
|
||||||
|
using AI based on your staged or unstaged changes. You can configure the model used in
|
||||||
|
Model Defaults.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const QUICK_TASKS: PhaseConfig[] = [
|
|||||||
label: 'Image Descriptions',
|
label: 'Image Descriptions',
|
||||||
description: 'Analyzes and describes context images',
|
description: 'Analyzes and describes context images',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'commitMessageModel',
|
||||||
|
label: 'Commit Messages',
|
||||||
|
description: 'Generates git commit messages from diffs',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const VALIDATION_TASKS: PhaseConfig[] = [
|
const VALIDATION_TASKS: PhaseConfig[] = [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import type {
|
import type {
|
||||||
ModelAlias,
|
ModelAlias,
|
||||||
CursorModelId,
|
CursorModelId,
|
||||||
@@ -167,6 +168,9 @@ export function PhaseModelSelector({
|
|||||||
dynamicOpencodeModels,
|
dynamicOpencodeModels,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
|
// Detect mobile devices to use inline expansion instead of nested popovers
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Extract model and thinking/reasoning levels from value
|
// Extract model and thinking/reasoning levels from value
|
||||||
const selectedModel = value.model;
|
const selectedModel = value.model;
|
||||||
const selectedThinkingLevel = value.thinkingLevel || 'none';
|
const selectedThinkingLevel = value.thinkingLevel || 'none';
|
||||||
@@ -585,6 +589,107 @@ export function PhaseModelSelector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Model supports reasoning - show popover with reasoning effort options
|
// Model supports reasoning - show popover with reasoning effort options
|
||||||
|
// On mobile, render inline expansion instead of nested popover
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div key={model.id}>
|
||||||
|
<CommandItem
|
||||||
|
value={model.label}
|
||||||
|
onSelect={() => setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))}
|
||||||
|
className="group flex items-center justify-between py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<OpenAIIcon
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0',
|
||||||
|
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col truncate">
|
||||||
|
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||||
|
{model.label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{isSelected && currentReasoning !== 'none'
|
||||||
|
? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}`
|
||||||
|
: model.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
|
||||||
|
isFavorite
|
||||||
|
? 'text-yellow-500 opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFavoriteModel(model.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
|
||||||
|
</Button>
|
||||||
|
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
|
||||||
|
{/* Inline reasoning effort options on mobile */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="pl-6 pr-2 pb-2 space-y-1">
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
Reasoning Effort
|
||||||
|
</div>
|
||||||
|
{REASONING_EFFORT_LEVELS.map((effort) => (
|
||||||
|
<button
|
||||||
|
key={effort}
|
||||||
|
onClick={() => {
|
||||||
|
onChange({
|
||||||
|
model: model.id as CodexModelId,
|
||||||
|
reasoningEffort: effort,
|
||||||
|
});
|
||||||
|
setExpandedCodexModel(null);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||||
|
'hover:bg-accent cursor-pointer transition-colors',
|
||||||
|
isSelected && currentReasoning === effort && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="font-medium text-xs">{REASONING_EFFORT_LABELS[effort]}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{effort === 'none' && 'No reasoning capability'}
|
||||||
|
{effort === 'minimal' && 'Minimal reasoning'}
|
||||||
|
{effort === 'low' && 'Light reasoning'}
|
||||||
|
{effort === 'medium' && 'Moderate reasoning'}
|
||||||
|
{effort === 'high' && 'Deep reasoning'}
|
||||||
|
{effort === 'xhigh' && 'Maximum reasoning'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && currentReasoning === effort && (
|
||||||
|
<Check className="h-3.5 w-3.5 text-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: Use nested popover
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
@@ -829,6 +934,106 @@ export function PhaseModelSelector({
|
|||||||
const isExpanded = expandedClaudeModel === model.id;
|
const isExpanded = expandedClaudeModel === model.id;
|
||||||
const currentThinking = isSelected ? selectedThinkingLevel : 'none';
|
const currentThinking = isSelected ? selectedThinkingLevel : 'none';
|
||||||
|
|
||||||
|
// On mobile, render inline expansion instead of nested popover
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div key={model.id}>
|
||||||
|
<CommandItem
|
||||||
|
value={model.label}
|
||||||
|
onSelect={() => setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))}
|
||||||
|
className="group flex items-center justify-between py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<AnthropicIcon
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0',
|
||||||
|
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col truncate">
|
||||||
|
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||||
|
{model.label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{isSelected && currentThinking !== 'none'
|
||||||
|
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
|
||||||
|
: model.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
|
||||||
|
isFavorite
|
||||||
|
? 'text-yellow-500 opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFavoriteModel(model.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
|
||||||
|
</Button>
|
||||||
|
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
|
||||||
|
{/* Inline thinking level options on mobile */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="pl-6 pr-2 pb-2 space-y-1">
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
Thinking Level
|
||||||
|
</div>
|
||||||
|
{THINKING_LEVELS.map((level) => (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => {
|
||||||
|
onChange({
|
||||||
|
model: model.id as ModelAlias,
|
||||||
|
thinkingLevel: level,
|
||||||
|
});
|
||||||
|
setExpandedClaudeModel(null);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||||
|
'hover:bg-accent cursor-pointer transition-colors',
|
||||||
|
isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="font-medium text-xs">{THINKING_LEVEL_LABELS[level]}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{level === 'none' && 'No extended thinking'}
|
||||||
|
{level === 'low' && 'Light reasoning (1k tokens)'}
|
||||||
|
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||||
|
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||||
|
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && currentThinking === level && (
|
||||||
|
<Check className="h-3.5 w-3.5 text-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: Use nested popover
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
@@ -963,6 +1168,90 @@ export function PhaseModelSelector({
|
|||||||
? 'Reasoning Mode'
|
? 'Reasoning Mode'
|
||||||
: 'Capacity Options';
|
: 'Capacity Options';
|
||||||
|
|
||||||
|
// On mobile, render inline expansion instead of nested popover
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div key={group.baseId}>
|
||||||
|
<CommandItem
|
||||||
|
value={group.label}
|
||||||
|
onSelect={() => setExpandedGroup(isExpanded ? null : group.baseId)}
|
||||||
|
className="group flex items-center justify-between py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<CursorIcon
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0',
|
||||||
|
groupIsSelected ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col truncate">
|
||||||
|
<span className={cn('truncate font-medium', groupIsSelected && 'text-primary')}>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{selectedVariant ? `Selected: ${selectedVariant.label}` : group.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
{groupIsSelected && !isExpanded && (
|
||||||
|
<Check className="h-4 w-4 text-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
|
||||||
|
{/* Inline variant options on mobile */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="pl-6 pr-2 pb-2 space-y-1">
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
{variantTypeLabel}
|
||||||
|
</div>
|
||||||
|
{group.variants.map((variant) => (
|
||||||
|
<button
|
||||||
|
key={variant.id}
|
||||||
|
onClick={() => {
|
||||||
|
onChange({ model: variant.id });
|
||||||
|
setExpandedGroup(null);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||||
|
'hover:bg-accent cursor-pointer transition-colors',
|
||||||
|
selectedModel === variant.id && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="font-medium text-xs">{variant.label}</span>
|
||||||
|
{variant.description && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{variant.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{variant.badge && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{variant.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedModel === variant.id && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: Use nested popover
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={group.baseId}
|
key={group.baseId}
|
||||||
@@ -1111,6 +1400,7 @@ export function PhaseModelSelector({
|
|||||||
className="w-[320px] p-0"
|
className="w-[320px] p-0"
|
||||||
align={align}
|
align={align}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
onTouchMove={(e) => e.stopPropagation()}
|
||||||
onPointerDownOutside={(e) => {
|
onPointerDownOutside={(e) => {
|
||||||
// Only prevent close if clicking inside a nested popover (thinking level panel)
|
// Only prevent close if clicking inside a nested popover (thinking level panel)
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
@@ -1123,7 +1413,7 @@ export function PhaseModelSelector({
|
|||||||
<CommandInput placeholder="Search models..." />
|
<CommandInput placeholder="Search models..." />
|
||||||
<CommandList
|
<CommandList
|
||||||
ref={commandListRef}
|
ref={commandListRef}
|
||||||
className="max-h-[300px] overflow-y-auto overscroll-contain"
|
className="max-h-[300px] overflow-y-auto overscroll-contain touch-pan-y"
|
||||||
>
|
>
|
||||||
<CommandEmpty>No model found.</CommandEmpty>
|
<CommandEmpty>No model found.</CommandEmpty>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Info,
|
Info,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
GitCommitHorizontal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { PromptCustomization, CustomPrompt } from '@automaker/types';
|
import type { PromptCustomization, CustomPrompt } from '@automaker/types';
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
DEFAULT_AGENT_PROMPTS,
|
DEFAULT_AGENT_PROMPTS,
|
||||||
DEFAULT_BACKLOG_PLAN_PROMPTS,
|
DEFAULT_BACKLOG_PLAN_PROMPTS,
|
||||||
DEFAULT_ENHANCEMENT_PROMPTS,
|
DEFAULT_ENHANCEMENT_PROMPTS,
|
||||||
|
DEFAULT_COMMIT_MESSAGE_PROMPTS,
|
||||||
} from '@automaker/prompts';
|
} from '@automaker/prompts';
|
||||||
|
|
||||||
interface PromptCustomizationSectionProps {
|
interface PromptCustomizationSectionProps {
|
||||||
@@ -219,7 +221,7 @@ export function PromptCustomizationSection({
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid grid-cols-4 w-full">
|
<TabsList className="grid grid-cols-5 w-full">
|
||||||
<TabsTrigger value="auto-mode" className="gap-2">
|
<TabsTrigger value="auto-mode" className="gap-2">
|
||||||
<Bot className="w-4 h-4" />
|
<Bot className="w-4 h-4" />
|
||||||
Auto Mode
|
Auto Mode
|
||||||
@@ -236,6 +238,10 @@ export function PromptCustomizationSection({
|
|||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
Enhancement
|
Enhancement
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="commit-message" className="gap-2">
|
||||||
|
<GitCommitHorizontal className="w-4 h-4" />
|
||||||
|
Commit
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Auto Mode Tab */}
|
{/* Auto Mode Tab */}
|
||||||
@@ -443,6 +449,34 @@ export function PromptCustomizationSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Commit Message Tab */}
|
||||||
|
<TabsContent value="commit-message" className="space-y-6 mt-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Commit Message Prompts</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resetToDefaults('commitMessage')}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
Reset Section
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PromptField
|
||||||
|
label="System Prompt"
|
||||||
|
description="Instructions for generating git commit messages from diffs. The AI will receive the git diff and generate a conventional commit message."
|
||||||
|
defaultValue={DEFAULT_COMMIT_MESSAGE_PROMPTS.systemPrompt}
|
||||||
|
customValue={promptCustomization?.commitMessage?.systemPrompt}
|
||||||
|
onCustomValueChange={(value) =>
|
||||||
|
updatePrompt('commitMessage', 'systemPrompt', value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
58
apps/ui/src/hooks/use-media-query.ts
Normal file
58
apps/ui/src/hooks/use-media-query.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if a media query matches
|
||||||
|
* @param query - The media query string (e.g., '(max-width: 768px)')
|
||||||
|
* @returns boolean indicating if the media query matches
|
||||||
|
*/
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track if this is the initial mount to avoid redundant setMatches call
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia(query);
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
setMatches(e.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only sync state when query changes after initial mount
|
||||||
|
// (initial mount already has correct value from useState initializer)
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
} else {
|
||||||
|
setMatches(mediaQuery.matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for changes
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if the device is mobile (screen width <= 768px)
|
||||||
|
* @returns boolean indicating if the device is mobile
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return useMediaQuery('(max-width: 768px)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if the device is tablet or smaller (screen width <= 1024px)
|
||||||
|
* @returns boolean indicating if the device is tablet or smaller
|
||||||
|
*/
|
||||||
|
export function useIsTablet(): boolean {
|
||||||
|
return useMediaQuery('(max-width: 1024px)');
|
||||||
|
}
|
||||||
102
apps/ui/src/hooks/use-provider-auth-init.ts
Normal file
102
apps/ui/src/hooks/use-provider-auth-init.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useSetupStore, type ClaudeAuthMethod, type CodexAuthMethod } from '@/store/setup-store';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
|
||||||
|
const logger = createLogger('ProviderAuthInit');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to initialize Claude and Codex authentication statuses on app startup.
|
||||||
|
* This ensures that usage tracking information is available in the board header
|
||||||
|
* without needing to visit the settings page first.
|
||||||
|
*/
|
||||||
|
export function useProviderAuthInit() {
|
||||||
|
const { setClaudeAuthStatus, setCodexAuthStatus, claudeAuthStatus, codexAuthStatus } =
|
||||||
|
useSetupStore();
|
||||||
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
const refreshStatuses = useCallback(async () => {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
|
||||||
|
// 1. Claude Auth Status
|
||||||
|
try {
|
||||||
|
const result = await api.setup.getClaudeStatus();
|
||||||
|
if (result.success && result.auth) {
|
||||||
|
// Cast to extended type that includes server-added fields
|
||||||
|
const auth = result.auth as typeof result.auth & {
|
||||||
|
oauthTokenValid?: boolean;
|
||||||
|
apiKeyValid?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validMethods: ClaudeAuthMethod[] = [
|
||||||
|
'oauth_token_env',
|
||||||
|
'oauth_token',
|
||||||
|
'api_key',
|
||||||
|
'api_key_env',
|
||||||
|
'credentials_file',
|
||||||
|
'cli_authenticated',
|
||||||
|
'none',
|
||||||
|
];
|
||||||
|
|
||||||
|
const method = validMethods.includes(auth.method as ClaudeAuthMethod)
|
||||||
|
? (auth.method as ClaudeAuthMethod)
|
||||||
|
: ((auth.authenticated ? 'api_key' : 'none') as ClaudeAuthMethod);
|
||||||
|
|
||||||
|
setClaudeAuthStatus({
|
||||||
|
authenticated: auth.authenticated,
|
||||||
|
method,
|
||||||
|
hasCredentialsFile: auth.hasCredentialsFile ?? false,
|
||||||
|
oauthTokenValid: !!(
|
||||||
|
auth.oauthTokenValid ||
|
||||||
|
auth.hasStoredOAuthToken ||
|
||||||
|
auth.hasEnvOAuthToken
|
||||||
|
),
|
||||||
|
apiKeyValid: !!(auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey),
|
||||||
|
hasEnvOAuthToken: !!auth.hasEnvOAuthToken,
|
||||||
|
hasEnvApiKey: !!auth.hasEnvApiKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to init Claude auth status:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Codex Auth Status
|
||||||
|
try {
|
||||||
|
const result = await api.setup.getCodexStatus();
|
||||||
|
if (result.success && result.auth) {
|
||||||
|
const auth = result.auth;
|
||||||
|
|
||||||
|
const validMethods: CodexAuthMethod[] = [
|
||||||
|
'api_key_env',
|
||||||
|
'api_key',
|
||||||
|
'cli_authenticated',
|
||||||
|
'none',
|
||||||
|
];
|
||||||
|
|
||||||
|
const method = validMethods.includes(auth.method as CodexAuthMethod)
|
||||||
|
? (auth.method as CodexAuthMethod)
|
||||||
|
: ((auth.authenticated ? 'api_key' : 'none') as CodexAuthMethod);
|
||||||
|
|
||||||
|
setCodexAuthStatus({
|
||||||
|
authenticated: auth.authenticated,
|
||||||
|
method,
|
||||||
|
hasAuthFile: auth.hasAuthFile ?? false,
|
||||||
|
hasApiKey: auth.hasApiKey ?? false,
|
||||||
|
hasEnvApiKey: auth.hasEnvApiKey ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to init Codex auth status:', error);
|
||||||
|
}
|
||||||
|
}, [setClaudeAuthStatus, setCodexAuthStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only initialize once per session if not already set
|
||||||
|
if (initialized.current || (claudeAuthStatus !== null && codexAuthStatus !== null)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialized.current = true;
|
||||||
|
|
||||||
|
void refreshStatuses();
|
||||||
|
}, [refreshStatuses, claudeAuthStatus, codexAuthStatus]);
|
||||||
|
}
|
||||||
@@ -1543,6 +1543,14 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
generateCommitMessage: async (worktreePath: string) => {
|
||||||
|
console.log('[Mock] Generating commit message for:', worktreePath);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'feat: Add mock commit message generation',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
push: async (worktreePath: string, force?: boolean) => {
|
push: async (worktreePath: string, force?: boolean) => {
|
||||||
console.log('[Mock] Pushing worktree:', { worktreePath, force });
|
console.log('[Mock] Pushing worktree:', { worktreePath, force });
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1683,6 +1683,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}),
|
}),
|
||||||
commit: (worktreePath: string, message: string) =>
|
commit: (worktreePath: string, message: string) =>
|
||||||
this.post('/api/worktree/commit', { worktreePath, message }),
|
this.post('/api/worktree/commit', { worktreePath, message }),
|
||||||
|
generateCommitMessage: (worktreePath: string) =>
|
||||||
|
this.post('/api/worktree/generate-commit-message', { worktreePath }),
|
||||||
push: (worktreePath: string, force?: boolean) =>
|
push: (worktreePath: string, force?: boolean) =>
|
||||||
this.post('/api/worktree/push', { worktreePath, force }),
|
this.post('/api/worktree/push', { worktreePath, force }),
|
||||||
createPR: (worktreePath: string, options?: any) =>
|
createPR: (worktreePath: string, options?: any) =>
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
|
|||||||
|
|
||||||
export interface Feature extends Omit<
|
export interface Feature extends Omit<
|
||||||
BaseFeature,
|
BaseFeature,
|
||||||
'steps' | 'imagePaths' | 'textFilePaths' | 'status'
|
'steps' | 'imagePaths' | 'textFilePaths' | 'status' | 'planSpec'
|
||||||
> {
|
> {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -354,6 +354,7 @@ export interface Feature extends Omit<
|
|||||||
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
||||||
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
||||||
prUrl?: string; // UI-specific: Pull request URL
|
prUrl?: string; // UI-specific: Pull request URL
|
||||||
|
planSpec?: PlanSpec; // Explicit planSpec type to override BaseFeature's index signature
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parsed task from spec (for spec and full planning modes)
|
// Parsed task from spec (for spec and full planning modes)
|
||||||
@@ -536,6 +537,7 @@ export interface AppState {
|
|||||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
||||||
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
|
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
|
||||||
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
|
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
|
||||||
|
enableAiCommitMessages: boolean; // When true, auto-generate commit messages using AI when opening commit dialog
|
||||||
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
|
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
|
||||||
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
|
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
|
||||||
|
|
||||||
@@ -937,6 +939,7 @@ export interface AppActions {
|
|||||||
setDefaultSkipTests: (skip: boolean) => void;
|
setDefaultSkipTests: (skip: boolean) => void;
|
||||||
setEnableDependencyBlocking: (enabled: boolean) => void;
|
setEnableDependencyBlocking: (enabled: boolean) => void;
|
||||||
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
|
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
|
||||||
|
setEnableAiCommitMessages: (enabled: boolean) => Promise<void>;
|
||||||
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||||
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
@@ -1224,6 +1227,7 @@ const initialState: AppState = {
|
|||||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||||
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
|
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
|
||||||
|
enableAiCommitMessages: true, // Default to enabled (auto-generate commit messages)
|
||||||
planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch)
|
planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch)
|
||||||
addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults)
|
addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults)
|
||||||
useWorktrees: true, // Default to enabled (git worktree isolation)
|
useWorktrees: true, // Default to enabled (git worktree isolation)
|
||||||
@@ -1907,6 +1911,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
await syncSettingsToServer();
|
await syncSettingsToServer();
|
||||||
},
|
},
|
||||||
|
setEnableAiCommitMessages: async (enabled) => {
|
||||||
|
const previous = get().enableAiCommitMessages;
|
||||||
|
set({ enableAiCommitMessages: enabled });
|
||||||
|
// Sync to server settings file
|
||||||
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
|
const ok = await syncSettingsToServer();
|
||||||
|
if (!ok) {
|
||||||
|
logger.error('Failed to sync enableAiCommitMessages setting to server - reverting');
|
||||||
|
set({ enableAiCommitMessages: previous });
|
||||||
|
}
|
||||||
|
},
|
||||||
setPlanUseSelectedWorktreeBranch: async (enabled) => {
|
setPlanUseSelectedWorktreeBranch: async (enabled) => {
|
||||||
const previous = get().planUseSelectedWorktreeBranch;
|
const previous = get().planUseSelectedWorktreeBranch;
|
||||||
set({ planUseSelectedWorktreeBranch: enabled });
|
set({ planUseSelectedWorktreeBranch: enabled });
|
||||||
|
|||||||
7
apps/ui/src/types/electron.d.ts
vendored
7
apps/ui/src/types/electron.d.ts
vendored
@@ -770,6 +770,13 @@ export interface WorktreeAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// Generate an AI commit message from git diff
|
||||||
|
generateCommitMessage: (worktreePath: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
// Push a worktree branch to remote
|
// Push a worktree branch to remote
|
||||||
push: (
|
push: (
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ Binary file ${cleanPath} added
|
|||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
|
|
||||||
// Remove trailing empty line if the file ends with newline
|
// Remove trailing empty line if the file ends with newline
|
||||||
if (lines.length > 0 && lines.at(-1) === '') {
|
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
||||||
lines.pop();
|
lines.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
ResolvedAgentPrompts,
|
ResolvedAgentPrompts,
|
||||||
ResolvedBacklogPlanPrompts,
|
ResolvedBacklogPlanPrompts,
|
||||||
ResolvedEnhancementPrompts,
|
ResolvedEnhancementPrompts,
|
||||||
|
ResolvedCommitMessagePrompts,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { STATIC_PORT, SERVER_PORT } from '@automaker/types';
|
import { STATIC_PORT, SERVER_PORT } from '@automaker/types';
|
||||||
|
|
||||||
@@ -429,6 +430,40 @@ export const DEFAULT_ENHANCEMENT_PROMPTS: ResolvedEnhancementPrompts = {
|
|||||||
uxReviewerSystemPrompt: UX_REVIEWER_SYSTEM_PROMPT,
|
uxReviewerSystemPrompt: UX_REVIEWER_SYSTEM_PROMPT,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ========================================================================
|
||||||
|
* COMMIT MESSAGE PROMPTS
|
||||||
|
* ========================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_COMMIT_MESSAGE_SYSTEM_PROMPT = `You are a git commit message generator. Your task is to create a clear, concise commit message based on the git diff provided.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Output ONLY the commit message, nothing else
|
||||||
|
- First line should be a short summary (50 chars or less) in imperative mood
|
||||||
|
- Start with a conventional commit type if appropriate (feat:, fix:, refactor:, docs:, chore:, style:, test:, perf:, ci:, build:)
|
||||||
|
- Keep it concise and descriptive
|
||||||
|
- Focus on WHAT changed and WHY (if clear from the diff), not HOW
|
||||||
|
- No quotes, backticks, or extra formatting
|
||||||
|
- If there are multiple changes, provide a brief summary on the first line
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- feat: Add dark mode toggle to settings
|
||||||
|
- fix: Resolve login validation edge case
|
||||||
|
- refactor: Extract user authentication logic
|
||||||
|
- docs: Update installation instructions
|
||||||
|
- chore: Update dependencies to latest versions
|
||||||
|
- style: Fix inconsistent indentation in components
|
||||||
|
- test: Add unit tests for user service
|
||||||
|
- perf: Optimize database query for user lookup`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default Commit Message prompts (for AI commit message generation)
|
||||||
|
*/
|
||||||
|
export const DEFAULT_COMMIT_MESSAGE_PROMPTS: ResolvedCommitMessagePrompts = {
|
||||||
|
systemPrompt: DEFAULT_COMMIT_MESSAGE_SYSTEM_PROMPT,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ========================================================================
|
* ========================================================================
|
||||||
* COMBINED DEFAULTS
|
* COMBINED DEFAULTS
|
||||||
@@ -443,4 +478,5 @@ export const DEFAULT_PROMPTS = {
|
|||||||
agent: DEFAULT_AGENT_PROMPTS,
|
agent: DEFAULT_AGENT_PROMPTS,
|
||||||
backlogPlan: DEFAULT_BACKLOG_PLAN_PROMPTS,
|
backlogPlan: DEFAULT_BACKLOG_PLAN_PROMPTS,
|
||||||
enhancement: DEFAULT_ENHANCEMENT_PROMPTS,
|
enhancement: DEFAULT_ENHANCEMENT_PROMPTS,
|
||||||
|
commitMessage: DEFAULT_COMMIT_MESSAGE_PROMPTS,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export {
|
|||||||
DEFAULT_BACKLOG_PLAN_USER_PROMPT_TEMPLATE,
|
DEFAULT_BACKLOG_PLAN_USER_PROMPT_TEMPLATE,
|
||||||
DEFAULT_BACKLOG_PLAN_PROMPTS,
|
DEFAULT_BACKLOG_PLAN_PROMPTS,
|
||||||
DEFAULT_ENHANCEMENT_PROMPTS,
|
DEFAULT_ENHANCEMENT_PROMPTS,
|
||||||
|
DEFAULT_COMMIT_MESSAGE_SYSTEM_PROMPT,
|
||||||
|
DEFAULT_COMMIT_MESSAGE_PROMPTS,
|
||||||
DEFAULT_PROMPTS,
|
DEFAULT_PROMPTS,
|
||||||
} from './defaults.js';
|
} from './defaults.js';
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ export {
|
|||||||
mergeAgentPrompts,
|
mergeAgentPrompts,
|
||||||
mergeBacklogPlanPrompts,
|
mergeBacklogPlanPrompts,
|
||||||
mergeEnhancementPrompts,
|
mergeEnhancementPrompts,
|
||||||
|
mergeCommitMessagePrompts,
|
||||||
mergeAllPrompts,
|
mergeAllPrompts,
|
||||||
} from './merge.js';
|
} from './merge.js';
|
||||||
|
|
||||||
@@ -59,4 +62,5 @@ export type {
|
|||||||
ResolvedAgentPrompts,
|
ResolvedAgentPrompts,
|
||||||
ResolvedBacklogPlanPrompts,
|
ResolvedBacklogPlanPrompts,
|
||||||
ResolvedEnhancementPrompts,
|
ResolvedEnhancementPrompts,
|
||||||
|
ResolvedCommitMessagePrompts,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|||||||
@@ -14,17 +14,20 @@ import type {
|
|||||||
AgentPrompts,
|
AgentPrompts,
|
||||||
BacklogPlanPrompts,
|
BacklogPlanPrompts,
|
||||||
EnhancementPrompts,
|
EnhancementPrompts,
|
||||||
|
CommitMessagePrompts,
|
||||||
CustomPrompt,
|
CustomPrompt,
|
||||||
ResolvedAutoModePrompts,
|
ResolvedAutoModePrompts,
|
||||||
ResolvedAgentPrompts,
|
ResolvedAgentPrompts,
|
||||||
ResolvedBacklogPlanPrompts,
|
ResolvedBacklogPlanPrompts,
|
||||||
ResolvedEnhancementPrompts,
|
ResolvedEnhancementPrompts,
|
||||||
|
ResolvedCommitMessagePrompts,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
DEFAULT_AUTO_MODE_PROMPTS,
|
DEFAULT_AUTO_MODE_PROMPTS,
|
||||||
DEFAULT_AGENT_PROMPTS,
|
DEFAULT_AGENT_PROMPTS,
|
||||||
DEFAULT_BACKLOG_PLAN_PROMPTS,
|
DEFAULT_BACKLOG_PLAN_PROMPTS,
|
||||||
DEFAULT_ENHANCEMENT_PROMPTS,
|
DEFAULT_ENHANCEMENT_PROMPTS,
|
||||||
|
DEFAULT_COMMIT_MESSAGE_PROMPTS,
|
||||||
} from './defaults.js';
|
} from './defaults.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,6 +123,18 @@ export function mergeEnhancementPrompts(custom?: EnhancementPrompts): ResolvedEn
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge custom Commit Message prompts with defaults
|
||||||
|
* Custom prompts override defaults only when enabled=true
|
||||||
|
*/
|
||||||
|
export function mergeCommitMessagePrompts(
|
||||||
|
custom?: CommitMessagePrompts
|
||||||
|
): ResolvedCommitMessagePrompts {
|
||||||
|
return {
|
||||||
|
systemPrompt: resolvePrompt(custom?.systemPrompt, DEFAULT_COMMIT_MESSAGE_PROMPTS.systemPrompt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge all custom prompts with defaults
|
* Merge all custom prompts with defaults
|
||||||
* Returns a complete PromptCustomization with all fields populated
|
* Returns a complete PromptCustomization with all fields populated
|
||||||
@@ -130,5 +145,6 @@ export function mergeAllPrompts(custom?: PromptCustomization) {
|
|||||||
agent: mergeAgentPrompts(custom?.agent),
|
agent: mergeAgentPrompts(custom?.agent),
|
||||||
backlogPlan: mergeBacklogPlanPrompts(custom?.backlogPlan),
|
backlogPlan: mergeBacklogPlanPrompts(custom?.backlogPlan),
|
||||||
enhancement: mergeEnhancementPrompts(custom?.enhancement),
|
enhancement: mergeEnhancementPrompts(custom?.enhancement),
|
||||||
|
commitMessage: mergeCommitMessagePrompts(custom?.commitMessage),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,11 +99,13 @@ export type {
|
|||||||
AgentPrompts,
|
AgentPrompts,
|
||||||
BacklogPlanPrompts,
|
BacklogPlanPrompts,
|
||||||
EnhancementPrompts,
|
EnhancementPrompts,
|
||||||
|
CommitMessagePrompts,
|
||||||
PromptCustomization,
|
PromptCustomization,
|
||||||
ResolvedAutoModePrompts,
|
ResolvedAutoModePrompts,
|
||||||
ResolvedAgentPrompts,
|
ResolvedAgentPrompts,
|
||||||
ResolvedBacklogPlanPrompts,
|
ResolvedBacklogPlanPrompts,
|
||||||
ResolvedEnhancementPrompts,
|
ResolvedEnhancementPrompts,
|
||||||
|
ResolvedCommitMessagePrompts,
|
||||||
} from './prompts.js';
|
} from './prompts.js';
|
||||||
export { DEFAULT_PROMPT_CUSTOMIZATION } from './prompts.js';
|
export { DEFAULT_PROMPT_CUSTOMIZATION } from './prompts.js';
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,16 @@ export interface EnhancementPrompts {
|
|||||||
uxReviewerSystemPrompt?: CustomPrompt;
|
uxReviewerSystemPrompt?: CustomPrompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CommitMessagePrompts - Customizable prompts for AI commit message generation
|
||||||
|
*
|
||||||
|
* Controls how the AI generates git commit messages from diffs.
|
||||||
|
*/
|
||||||
|
export interface CommitMessagePrompts {
|
||||||
|
/** System prompt for generating commit messages */
|
||||||
|
systemPrompt?: CustomPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PromptCustomization - Complete set of customizable prompts
|
* PromptCustomization - Complete set of customizable prompts
|
||||||
*
|
*
|
||||||
@@ -112,6 +122,9 @@ export interface PromptCustomization {
|
|||||||
|
|
||||||
/** Enhancement prompts (feature description improvement) */
|
/** Enhancement prompts (feature description improvement) */
|
||||||
enhancement?: EnhancementPrompts;
|
enhancement?: EnhancementPrompts;
|
||||||
|
|
||||||
|
/** Commit message prompts (AI-generated commit messages) */
|
||||||
|
commitMessage?: CommitMessagePrompts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,6 +135,7 @@ export const DEFAULT_PROMPT_CUSTOMIZATION: PromptCustomization = {
|
|||||||
agent: {},
|
agent: {},
|
||||||
backlogPlan: {},
|
backlogPlan: {},
|
||||||
enhancement: {},
|
enhancement: {},
|
||||||
|
commitMessage: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,3 +169,7 @@ export interface ResolvedEnhancementPrompts {
|
|||||||
acceptanceSystemPrompt: string;
|
acceptanceSystemPrompt: string;
|
||||||
uxReviewerSystemPrompt: string;
|
uxReviewerSystemPrompt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCommitMessagePrompts {
|
||||||
|
systemPrompt: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ export interface PhaseModelConfig {
|
|||||||
// Memory tasks - for learning extraction and memory operations
|
// Memory tasks - for learning extraction and memory operations
|
||||||
/** Model for extracting learnings from completed agent sessions */
|
/** Model for extracting learnings from completed agent sessions */
|
||||||
memoryExtractionModel: PhaseModelEntry;
|
memoryExtractionModel: PhaseModelEntry;
|
||||||
|
|
||||||
|
// Quick tasks - commit messages
|
||||||
|
/** Model for generating git commit messages from diffs */
|
||||||
|
commitMessageModel: PhaseModelEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keys of PhaseModelConfig for type-safe access */
|
/** Keys of PhaseModelConfig for type-safe access */
|
||||||
@@ -386,6 +390,10 @@ export interface GlobalSettings {
|
|||||||
/** Mute completion notification sound */
|
/** Mute completion notification sound */
|
||||||
muteDoneSound: boolean;
|
muteDoneSound: boolean;
|
||||||
|
|
||||||
|
// AI Commit Message Generation
|
||||||
|
/** Enable AI-generated commit messages when opening commit dialog (default: true) */
|
||||||
|
enableAiCommitMessages: boolean;
|
||||||
|
|
||||||
// AI Model Selection (per-phase configuration)
|
// AI Model Selection (per-phase configuration)
|
||||||
/** Phase-specific AI model configuration */
|
/** Phase-specific AI model configuration */
|
||||||
phaseModels: PhaseModelConfig;
|
phaseModels: PhaseModelConfig;
|
||||||
@@ -661,6 +669,9 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
|
|||||||
|
|
||||||
// Memory - use fast model for learning extraction (cost-effective)
|
// Memory - use fast model for learning extraction (cost-effective)
|
||||||
memoryExtractionModel: { model: 'haiku' },
|
memoryExtractionModel: { model: 'haiku' },
|
||||||
|
|
||||||
|
// Commit messages - use fast model for speed
|
||||||
|
commitMessageModel: { model: 'haiku' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Current version of the global settings schema */
|
/** Current version of the global settings schema */
|
||||||
@@ -710,6 +721,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
defaultRequirePlanApproval: false,
|
defaultRequirePlanApproval: false,
|
||||||
defaultFeatureModel: { model: 'opus' },
|
defaultFeatureModel: { model: 'opus' },
|
||||||
muteDoneSound: false,
|
muteDoneSound: false,
|
||||||
|
enableAiCommitMessages: true,
|
||||||
phaseModels: DEFAULT_PHASE_MODELS,
|
phaseModels: DEFAULT_PHASE_MODELS,
|
||||||
enhancementModel: 'sonnet',
|
enhancementModel: 'sonnet',
|
||||||
validationModel: 'opus',
|
validationModel: 'opus',
|
||||||
|
|||||||
Reference in New Issue
Block a user