mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge remote-tracking branch 'upstream/v0.12.0rc' into patchcraft
This commit is contained in:
108
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
108
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement for Automaker
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest a feature! Please fill out the form below to help us understand your request.
|
||||
|
||||
- type: dropdown
|
||||
id: feature-area
|
||||
attributes:
|
||||
label: Feature Area
|
||||
description: Which area of Automaker does this feature relate to?
|
||||
options:
|
||||
- UI/UX (User Interface)
|
||||
- Agent/AI
|
||||
- Kanban Board
|
||||
- Git/Worktree Management
|
||||
- Project Management
|
||||
- Settings/Configuration
|
||||
- Documentation
|
||||
- Performance
|
||||
- Other
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Priority
|
||||
description: How important is this feature to your workflow?
|
||||
options:
|
||||
- Nice to have
|
||||
- Would improve my workflow
|
||||
- Critical for my use case
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-statement
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: Is your feature request related to a problem? Please describe the problem you're trying to solve.
|
||||
placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like to see implemented.
|
||||
placeholder: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives-considered
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or workarounds you've considered.
|
||||
placeholder: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: use-cases
|
||||
attributes:
|
||||
label: Use Cases
|
||||
description: Describe specific scenarios where this feature would be useful.
|
||||
placeholder: |
|
||||
1. When working on...
|
||||
2. As a user who needs to...
|
||||
3. In situations where...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: mockups
|
||||
attributes:
|
||||
label: Mockups/Screenshots
|
||||
description: If applicable, add mockups, wireframes, or screenshots to help illustrate your feature request.
|
||||
placeholder: Drag and drop images here or paste image URLs
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, references, or examples about the feature request here.
|
||||
placeholder: Any additional information that might be helpful...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have searched existing issues to ensure this feature hasn't been requested already
|
||||
required: true
|
||||
- label: I have provided a clear description of the problem and proposed solution
|
||||
required: true
|
||||
@@ -31,7 +31,12 @@ fi
|
||||
|
||||
# Ensure common system paths are in PATH (for systems without nvm)
|
||||
# This helps find node/npm installed via Homebrew, system packages, etc.
|
||||
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
|
||||
if [ -n "$WINDIR" ]; then
|
||||
export PATH="$PATH:/c/Program Files/nodejs:/c/Program Files (x86)/nodejs"
|
||||
export PATH="$PATH:$APPDATA/npm:$LOCALAPPDATA/Programs/nodejs"
|
||||
else
|
||||
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
|
||||
fi
|
||||
|
||||
# Run lint-staged - works with or without nvm
|
||||
# Prefer npx, fallback to npm exec, both work with system-installed Node.js
|
||||
|
||||
@@ -65,8 +65,16 @@ ARG UID=1001
|
||||
ARG GID=1001
|
||||
|
||||
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
|
||||
# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git curl bash gosu ca-certificates openssh-client \
|
||||
# Playwright/Chromium dependencies
|
||||
libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
|
||||
libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \
|
||||
libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
|
||||
libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \
|
||||
libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \
|
||||
xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \
|
||||
&& GH_VERSION="2.63.2" \
|
||||
&& ARCH=$(uname -m) \
|
||||
&& case "$ARCH" in \
|
||||
|
||||
@@ -8,9 +8,17 @@
|
||||
FROM node:22-slim
|
||||
|
||||
# Install build dependencies for native modules (node-pty) and runtime tools
|
||||
# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
git curl bash gosu ca-certificates openssh-client \
|
||||
# Playwright/Chromium dependencies
|
||||
libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
|
||||
libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \
|
||||
libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
|
||||
libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \
|
||||
libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \
|
||||
xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \
|
||||
&& GH_VERSION="2.63.2" \
|
||||
&& ARCH=$(uname -m) \
|
||||
&& case "$ARCH" in \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
|
||||
@@ -67,6 +67,7 @@ import { createPipelineRoutes } from './routes/pipeline/index.js';
|
||||
import { pipelineService } from './services/pipeline-service.js';
|
||||
import { createIdeationRoutes } from './routes/ideation/index.js';
|
||||
import { IdeationService } from './services/ideation-service.js';
|
||||
import { getDevServerService } from './services/dev-server-service.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -176,6 +177,10 @@ const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
|
||||
// Initialize DevServerService with event emitter for real-time log streaming
|
||||
const devServerService = getDevServerService();
|
||||
devServerService.setEventEmitter(events);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
await agentService.initialize();
|
||||
@@ -217,7 +222,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService));
|
||||
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
||||
app.use('/api/git', createGitRoutes());
|
||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||
app.use('/api/models', createModelsRoutes());
|
||||
|
||||
@@ -8,12 +8,28 @@ import type { Request, Response, NextFunction } from 'express';
|
||||
import { validatePath, PathNotAllowedError } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
* Creates a middleware that validates specified path parameters in req.body
|
||||
* Helper to get parameter value from request (checks body first, then query)
|
||||
*/
|
||||
function getParamValue(req: Request, paramName: string): unknown {
|
||||
// Check body first (for POST/PUT/PATCH requests)
|
||||
if (req.body && req.body[paramName] !== undefined) {
|
||||
return req.body[paramName];
|
||||
}
|
||||
// Fall back to query params (for GET requests)
|
||||
if (req.query && req.query[paramName] !== undefined) {
|
||||
return req.query[paramName];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a middleware that validates specified path parameters in req.body or req.query
|
||||
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
|
||||
* @example
|
||||
* router.post('/create', validatePathParams('projectPath'), handler);
|
||||
* router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler);
|
||||
* router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler);
|
||||
* router.get('/logs', validatePathParams('worktreePath'), handler); // Works with query params too
|
||||
*
|
||||
* Special syntax:
|
||||
* - 'paramName?' - Optional parameter (only validated if present)
|
||||
@@ -26,8 +42,8 @@ export function validatePathParams(...paramNames: string[]) {
|
||||
// Handle optional parameters (paramName?)
|
||||
if (paramName.endsWith('?')) {
|
||||
const actualName = paramName.slice(0, -1);
|
||||
const value = req.body[actualName];
|
||||
if (value) {
|
||||
const value = getParamValue(req, actualName);
|
||||
if (value && typeof value === 'string') {
|
||||
validatePath(value);
|
||||
}
|
||||
continue;
|
||||
@@ -36,18 +52,20 @@ export function validatePathParams(...paramNames: string[]) {
|
||||
// Handle array parameters (paramName[])
|
||||
if (paramName.endsWith('[]')) {
|
||||
const actualName = paramName.slice(0, -2);
|
||||
const values = req.body[actualName];
|
||||
const values = getParamValue(req, actualName);
|
||||
if (Array.isArray(values) && values.length > 0) {
|
||||
for (const value of values) {
|
||||
validatePath(value);
|
||||
if (typeof value === 'string') {
|
||||
validatePath(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle regular parameters
|
||||
const value = req.body[paramName];
|
||||
if (value) {
|
||||
const value = getParamValue(req, paramName);
|
||||
if (value && typeof value === 'string') {
|
||||
validatePath(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import type {
|
||||
// Only these vars are passed - nothing else from process.env leaks through.
|
||||
const ALLOWED_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'PATH',
|
||||
'HOME',
|
||||
'SHELL',
|
||||
|
||||
@@ -26,22 +26,22 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import type { ProviderConfig, ExecuteOptions, ProviderMessage } from './types.js';
|
||||
import {
|
||||
spawnJSONLProcess,
|
||||
type SubprocessOptions,
|
||||
isWslAvailable,
|
||||
findCliInWsl,
|
||||
createWslCommand,
|
||||
findCliInWsl,
|
||||
isWslAvailable,
|
||||
spawnJSONLProcess,
|
||||
windowsToWslPath,
|
||||
type SubprocessOptions,
|
||||
type WslCliResult,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import type { ExecuteOptions, ProviderConfig, ProviderMessage } from './types.js';
|
||||
|
||||
/**
|
||||
* Spawn strategy for CLI tools on Windows
|
||||
@@ -522,8 +522,13 @@ export abstract class CliProvider extends BaseProvider {
|
||||
throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`);
|
||||
}
|
||||
|
||||
const cliArgs = this.buildCliArgs(options);
|
||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
||||
// Many CLI-based providers do not support a separate "system" message.
|
||||
// If a systemPrompt is provided, embed it into the prompt so downstream models
|
||||
// still receive critical formatting/schema instructions (e.g., JSON-only outputs).
|
||||
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
|
||||
|
||||
const cliArgs = this.buildCliArgs(effectiveOptions);
|
||||
const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs);
|
||||
|
||||
try {
|
||||
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
|
||||
@@ -555,4 +560,52 @@ export abstract class CliProvider extends BaseProvider {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed system prompt text into the user prompt for CLI providers.
|
||||
*
|
||||
* Most CLI providers we integrate with only accept a single prompt via stdin/args.
|
||||
* When upstream code supplies `options.systemPrompt`, we prepend it to the prompt
|
||||
* content and clear `systemPrompt` to avoid any accidental double-injection by
|
||||
* subclasses.
|
||||
*/
|
||||
protected embedSystemPromptIntoPrompt(options: ExecuteOptions): ExecuteOptions {
|
||||
if (!options.systemPrompt) {
|
||||
return options;
|
||||
}
|
||||
|
||||
// Only string system prompts can be reliably embedded for CLI providers.
|
||||
// Presets are provider-specific (e.g., Claude SDK) and cannot be represented
|
||||
// universally. If a preset is provided, we only embed its optional `append`.
|
||||
const systemText =
|
||||
typeof options.systemPrompt === 'string'
|
||||
? options.systemPrompt
|
||||
: options.systemPrompt.append
|
||||
? options.systemPrompt.append
|
||||
: '';
|
||||
|
||||
if (!systemText) {
|
||||
return { ...options, systemPrompt: undefined };
|
||||
}
|
||||
|
||||
// Preserve original prompt structure.
|
||||
if (typeof options.prompt === 'string') {
|
||||
return {
|
||||
...options,
|
||||
prompt: `${systemText}\n\n---\n\n${options.prompt}`,
|
||||
systemPrompt: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(options.prompt)) {
|
||||
return {
|
||||
...options,
|
||||
prompt: [{ type: 'text', text: systemText }, ...options.prompt],
|
||||
systemPrompt: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Should be unreachable due to ExecuteOptions typing, but keep safe.
|
||||
return { ...options, systemPrompt: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,7 +730,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
|
||||
if (this.detectedStrategy === 'npx') {
|
||||
// NPX strategy: execute npx with opencode-ai package
|
||||
command = 'npx';
|
||||
command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
args = ['opencode-ai@latest', 'models'];
|
||||
opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
|
||||
} else if (this.useWsl && this.wslCliPath) {
|
||||
@@ -751,6 +751,8 @@ export class OpencodeProvider extends CliProvider {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
windowsHide: true,
|
||||
// Use shell on Windows for .cmd files
|
||||
shell: process.platform === 'win32' && command.endsWith('.cmd'),
|
||||
});
|
||||
|
||||
opencodeLogger.debug(
|
||||
@@ -963,7 +965,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
|
||||
if (this.detectedStrategy === 'npx') {
|
||||
// NPX strategy
|
||||
command = 'npx';
|
||||
command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
args = ['opencode-ai@latest', 'auth', 'list'];
|
||||
opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
|
||||
} else if (this.useWsl && this.wslCliPath) {
|
||||
@@ -984,6 +986,8 @@ export class OpencodeProvider extends CliProvider {
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
windowsHide: true,
|
||||
// Use shell on Windows for .cmd files
|
||||
shell: process.platform === 'win32' && command.endsWith('.cmd'),
|
||||
});
|
||||
|
||||
opencodeLogger.debug(
|
||||
|
||||
@@ -34,6 +34,13 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
||||
error: 'Authentication required',
|
||||
message: "Please run 'claude login' to authenticate",
|
||||
});
|
||||
} else if (message.includes('TRUST_PROMPT_PENDING')) {
|
||||
// Trust prompt appeared but couldn't be auto-approved
|
||||
res.status(200).json({
|
||||
error: 'Trust prompt pending',
|
||||
message:
|
||||
'Claude CLI needs folder permission. Please run "claude" in your terminal and approve access.',
|
||||
});
|
||||
} else if (message.includes('timed out')) {
|
||||
res.status(200).json({
|
||||
error: 'Command timed out',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createDeleteHandler } from './routes/delete.js';
|
||||
import { createCreatePRHandler } from './routes/create-pr.js';
|
||||
import { createPRInfoHandler } from './routes/pr-info.js';
|
||||
import { createCommitHandler } from './routes/commit.js';
|
||||
import { createGenerateCommitMessageHandler } from './routes/generate-commit-message.js';
|
||||
import { createPushHandler } from './routes/push.js';
|
||||
import { createPullHandler } from './routes/pull.js';
|
||||
import { createCheckoutBranchHandler } from './routes/checkout-branch.js';
|
||||
@@ -33,14 +34,19 @@ import { createMigrateHandler } from './routes/migrate.js';
|
||||
import { createStartDevHandler } from './routes/start-dev.js';
|
||||
import { createStopDevHandler } from './routes/stop-dev.js';
|
||||
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
||||
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js';
|
||||
import {
|
||||
createGetInitScriptHandler,
|
||||
createPutInitScriptHandler,
|
||||
createDeleteInitScriptHandler,
|
||||
createRunInitScriptHandler,
|
||||
} from './routes/init-script.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createWorktreeRoutes(events: EventEmitter): Router {
|
||||
export function createWorktreeRoutes(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
||||
@@ -64,6 +70,12 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
|
||||
requireGitRepoOnly,
|
||||
createCommitHandler()
|
||||
);
|
||||
router.post(
|
||||
'/generate-commit-message',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createGenerateCommitMessageHandler(settingsService)
|
||||
);
|
||||
router.post(
|
||||
'/push',
|
||||
validatePathParams('worktreePath'),
|
||||
@@ -97,6 +109,11 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
|
||||
);
|
||||
router.post('/stop-dev', createStopDevHandler());
|
||||
router.post('/list-dev-servers', createListDevServersHandler());
|
||||
router.get(
|
||||
'/dev-server-logs',
|
||||
validatePathParams('worktreePath'),
|
||||
createGetDevServerLogsHandler()
|
||||
);
|
||||
|
||||
// Init script routes
|
||||
router.get('/init-script', createGetInitScriptHandler());
|
||||
|
||||
@@ -70,9 +70,8 @@ export function createCreatePRHandler() {
|
||||
logger.debug(`Changed files:\n${status}`);
|
||||
}
|
||||
|
||||
// If there are changes, commit them
|
||||
// If there are changes, commit them before creating the PR
|
||||
let commitHash: string | null = null;
|
||||
let commitError: string | null = null;
|
||||
if (hasChanges) {
|
||||
const message = commitMessage || `Changes from ${branchName}`;
|
||||
logger.debug(`Committing changes with message: ${message}`);
|
||||
@@ -98,14 +97,13 @@ export function createCreatePRHandler() {
|
||||
logger.info(`Commit successful: ${commitHash}`);
|
||||
} catch (commitErr: unknown) {
|
||||
const err = commitErr as { stderr?: string; message?: string };
|
||||
commitError = err.stderr || err.message || 'Commit failed';
|
||||
const commitError = err.stderr || err.message || 'Commit failed';
|
||||
logger.error(`Commit failed: ${commitError}`);
|
||||
|
||||
// Return error immediately - don't proceed with push/PR if commit fails
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `Failed to commit changes: ${commitError}`,
|
||||
commitError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -381,9 +379,8 @@ export function createCreatePRHandler() {
|
||||
success: true,
|
||||
result: {
|
||||
branch: branchName,
|
||||
committed: hasChanges && !commitError,
|
||||
committed: hasChanges,
|
||||
commitHash,
|
||||
commitError: commitError || undefined,
|
||||
pushed: true,
|
||||
prUrl,
|
||||
prNumber,
|
||||
|
||||
52
apps/server/src/routes/worktree/routes/dev-server-logs.ts
Normal file
52
apps/server/src/routes/worktree/routes/dev-server-logs.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* GET /dev-server-logs endpoint - Get buffered logs for a worktree's dev server
|
||||
*
|
||||
* Returns the scrollback buffer containing historical log output for a running
|
||||
* dev server. Used by clients to populate the log panel on initial connection
|
||||
* before subscribing to real-time updates via WebSocket.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getDevServerService } from '../../../services/dev-server-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createGetDevServerLogsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.query as {
|
||||
worktreePath?: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath query parameter is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const devServerService = getDevServerService();
|
||||
const result = devServerService.getServerLogs(worktreePath);
|
||||
|
||||
if (result.success && result.result) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath: result.result.worktreePath,
|
||||
port: result.result.port,
|
||||
logs: result.result.logs,
|
||||
startedAt: result.result.startedAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error || 'Failed to get dev server logs',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Get dev server logs failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* POST /list-branches endpoint - List all local branches
|
||||
* POST /list-branches endpoint - List all local branches and optionally remote branches
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
@@ -21,8 +21,9 @@ interface BranchInfo {
|
||||
export function createListBranchesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.body as {
|
||||
const { worktreePath, includeRemote = false } = req.body as {
|
||||
worktreePath: string;
|
||||
includeRemote?: boolean;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
@@ -60,6 +61,55 @@ export function createListBranchesHandler() {
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch remote branches if requested
|
||||
if (includeRemote) {
|
||||
try {
|
||||
// Fetch latest remote refs (silently, don't fail if offline)
|
||||
try {
|
||||
await execAsync('git fetch --all --quiet', {
|
||||
cwd: worktreePath,
|
||||
timeout: 10000, // 10 second timeout
|
||||
});
|
||||
} catch {
|
||||
// Ignore fetch errors - we'll use cached remote refs
|
||||
}
|
||||
|
||||
// List remote branches
|
||||
const { stdout: remoteBranchesOutput } = await execAsync(
|
||||
'git branch -r --format="%(refname:short)"',
|
||||
{ cwd: worktreePath }
|
||||
);
|
||||
|
||||
const localBranchNames = new Set(branches.map((b) => b.name));
|
||||
|
||||
remoteBranchesOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((b) => b.trim())
|
||||
.forEach((name) => {
|
||||
// Remove any surrounding quotes
|
||||
const cleanName = name.trim().replace(/^['"]|['"]$/g, '');
|
||||
// Skip HEAD pointers like "origin/HEAD"
|
||||
if (cleanName.includes('/HEAD')) return;
|
||||
|
||||
// Only add remote branches if a branch with the exact same name isn't already
|
||||
// in the list. This avoids duplicates if a local branch is named like a remote one.
|
||||
// Note: We intentionally include remote branches even when a local branch with the
|
||||
// same base name exists (e.g., show "origin/main" even if local "main" exists),
|
||||
// since users need to select remote branches as PR base targets.
|
||||
if (!localBranchNames.has(cleanName)) {
|
||||
branches.push({
|
||||
name: cleanName, // Keep full name like "origin/main"
|
||||
isCurrent: false,
|
||||
isRemote: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors fetching remote branches - return local branches only
|
||||
}
|
||||
}
|
||||
|
||||
// Get ahead/behind count for current branch
|
||||
let aheadCount = 0;
|
||||
let behindCount = 0;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
||||
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
|
||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
@@ -121,6 +121,52 @@ async function scanWorktreesDirectory(
|
||||
return discovered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch open PRs from GitHub and create a map of branch name to PR info.
|
||||
* This allows detecting PRs that were created outside the app.
|
||||
*/
|
||||
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
|
||||
const prMap = new Map<string, WorktreePRInfo>();
|
||||
|
||||
try {
|
||||
// Check if gh CLI is available
|
||||
const ghAvailable = await isGhCliAvailable();
|
||||
if (!ghAvailable) {
|
||||
return prMap;
|
||||
}
|
||||
|
||||
// Fetch open PRs from GitHub
|
||||
const { stdout } = await execAsync(
|
||||
'gh pr list --state open --json number,title,url,state,headRefName,createdAt --limit 1000',
|
||||
{ cwd: projectPath, env: execEnv, timeout: 15000 }
|
||||
);
|
||||
|
||||
const prs = JSON.parse(stdout || '[]') as Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: string;
|
||||
headRefName: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
|
||||
for (const pr of prs) {
|
||||
prMap.set(pr.headRefName, {
|
||||
number: pr.number,
|
||||
url: pr.url,
|
||||
title: pr.title,
|
||||
state: pr.state,
|
||||
createdAt: pr.createdAt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - PR detection is optional
|
||||
logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
return prMap;
|
||||
}
|
||||
|
||||
export function createListHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -241,11 +287,23 @@ export function createListHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add PR info from metadata for each worktree
|
||||
// Add PR info from metadata or GitHub for each worktree
|
||||
// Only fetch GitHub PRs if includeDetails is requested (performance optimization)
|
||||
const githubPRs = includeDetails
|
||||
? await fetchGitHubPRs(projectPath)
|
||||
: new Map<string, WorktreePRInfo>();
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
const metadata = allMetadata.get(worktree.branch);
|
||||
if (metadata?.pr) {
|
||||
// Use stored metadata (more complete info)
|
||||
worktree.pr = metadata.pr;
|
||||
} else if (includeDetails) {
|
||||
// Fall back to GitHub PR detection only when includeDetails is requested
|
||||
const githubPR = githubPRs.get(worktree.branch);
|
||||
if (githubPR) {
|
||||
worktree.pr = githubPR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -16,28 +15,31 @@ const execAsync = promisify(exec);
|
||||
export function createMergeHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, options } = req.body as {
|
||||
const { projectPath, branchName, worktreePath, options } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
branchName: string;
|
||||
worktreePath: string;
|
||||
options?: { squash?: boolean; message?: string };
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
if (!projectPath || !branchName || !worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath and featureId required',
|
||||
error: 'projectPath, branchName, and worktreePath are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const branchName = `feature/${featureId}`;
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
// Validate branch exists
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Branch "${branchName}" does not exist`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge the feature branch
|
||||
const mergeCmd = options?.squash
|
||||
|
||||
@@ -49,13 +49,11 @@ export class ClaudeUsageService {
|
||||
|
||||
/**
|
||||
* Execute the claude /usage command and return the output
|
||||
* Uses platform-specific PTY implementation
|
||||
* Uses node-pty on all platforms for consistency
|
||||
*/
|
||||
private executeClaudeUsageCommand(): Promise<string> {
|
||||
if (this.isWindows || this.isLinux) {
|
||||
return this.executeClaudeUsageCommandPty();
|
||||
}
|
||||
return this.executeClaudeUsageCommandMac();
|
||||
// Use node-pty on all platforms - it's more reliable than expect on macOS
|
||||
return this.executeClaudeUsageCommandPty();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,24 +65,36 @@ export class ClaudeUsageService {
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
// Use a simple working directory (home or tmp)
|
||||
const workingDirectory = process.env.HOME || '/tmp';
|
||||
// Use current working directory - likely already trusted by Claude CLI
|
||||
const workingDirectory = process.cwd();
|
||||
|
||||
// Use 'expect' with an inline script to run claude /usage with a PTY
|
||||
// Wait for "Current session" header, then wait for full output before exiting
|
||||
// Running from cwd which should already be trusted
|
||||
const expectScript = `
|
||||
set timeout 20
|
||||
set timeout 30
|
||||
spawn claude /usage
|
||||
|
||||
# Wait for usage data or handle trust prompt if needed
|
||||
expect {
|
||||
"Current session" {
|
||||
sleep 2
|
||||
send "\\x1b"
|
||||
-re "Ready to code|permission to work|Do you want to work" {
|
||||
# Trust prompt appeared - send Enter to approve
|
||||
sleep 1
|
||||
send "\\r"
|
||||
exp_continue
|
||||
}
|
||||
"Esc to cancel" {
|
||||
"Current session" {
|
||||
# Usage data appeared - wait for full output, then exit
|
||||
sleep 3
|
||||
send "\\x1b"
|
||||
}
|
||||
timeout {}
|
||||
"% left" {
|
||||
# Usage percentage appeared
|
||||
sleep 3
|
||||
send "\\x1b"
|
||||
}
|
||||
timeout {
|
||||
send "\\x1b"
|
||||
}
|
||||
eof {}
|
||||
}
|
||||
expect eof
|
||||
@@ -158,14 +168,18 @@ export class ClaudeUsageService {
|
||||
let output = '';
|
||||
let settled = false;
|
||||
let hasSeenUsageData = false;
|
||||
let hasSeenTrustPrompt = false;
|
||||
|
||||
const workingDirectory = this.isWindows
|
||||
? process.env.USERPROFILE || os.homedir() || 'C:\\'
|
||||
: process.env.HOME || os.homedir() || '/tmp';
|
||||
// Use current working directory (project dir) - most likely already trusted by Claude CLI
|
||||
const workingDirectory = process.cwd();
|
||||
|
||||
// Use platform-appropriate shell and command
|
||||
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;
|
||||
|
||||
@@ -181,8 +195,6 @@ export class ClaudeUsageService {
|
||||
} as Record<string, string>,
|
||||
});
|
||||
} 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);
|
||||
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||
|
||||
@@ -204,17 +216,60 @@ export class ClaudeUsageService {
|
||||
// Don't fail if we have data - return it instead
|
||||
if (output.includes('Current session')) {
|
||||
resolve(output);
|
||||
} else if (hasSeenTrustPrompt) {
|
||||
// Trust prompt was shown but we couldn't auto-approve it
|
||||
reject(
|
||||
new Error(
|
||||
'TRUST_PROMPT_PENDING: Claude CLI is waiting for folder permission. Please run "claude" in your terminal and approve access to continue.'
|
||||
)
|
||||
);
|
||||
} 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) => {
|
||||
output += data;
|
||||
|
||||
// Check if we've seen the usage data (look for "Current session")
|
||||
if (!hasSeenUsageData && output.includes('Current session')) {
|
||||
// Strip ANSI codes for easier matching
|
||||
// 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;
|
||||
// Wait for full output, then send escape to exit
|
||||
setTimeout(() => {
|
||||
@@ -228,16 +283,62 @@ export class ClaudeUsageService {
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}, 2000);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Handle Trust Dialog - multiple variants:
|
||||
// - "Do you want to work in this folder?"
|
||||
// - "Ready to code here?" / "I'll need permission to work with your files"
|
||||
// Since we are running in cwd (project dir), it is safe to approve.
|
||||
if (
|
||||
!hasApprovedTrust &&
|
||||
(cleanOutput.includes('Do you want to work in this folder?') ||
|
||||
cleanOutput.includes('Ready to code here') ||
|
||||
cleanOutput.includes('permission to work with your files'))
|
||||
) {
|
||||
hasApprovedTrust = true;
|
||||
hasSeenTrustPrompt = 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
|
||||
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(() => {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
}
|
||||
}, 3000);
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -246,8 +347,11 @@ export class ClaudeUsageService {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
// Check for authentication errors in output
|
||||
if (output.includes('token_expired') || output.includes('authentication_error')) {
|
||||
if (
|
||||
output.includes('token_expired') ||
|
||||
output.includes('authentication_error') ||
|
||||
output.includes('permission_error')
|
||||
) {
|
||||
reject(new Error("Authentication required - please run 'claude login'"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,24 +12,123 @@ import * as secureFs from '../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import net from 'net';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
|
||||
const logger = createLogger('DevServerService');
|
||||
|
||||
// Maximum scrollback buffer size (characters) - matches TerminalService pattern
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server
|
||||
|
||||
// Throttle output to prevent overwhelming WebSocket under heavy load
|
||||
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
|
||||
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
||||
|
||||
export interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
process: ChildProcess | null;
|
||||
startedAt: Date;
|
||||
// Scrollback buffer for log history (replay on reconnect)
|
||||
scrollbackBuffer: string;
|
||||
// Pending output to be flushed to subscribers
|
||||
outputBuffer: string;
|
||||
// Throttle timer for batching output
|
||||
flushTimeout: NodeJS.Timeout | null;
|
||||
// Flag to indicate server is stopping (prevents output after stop)
|
||||
stopping: boolean;
|
||||
}
|
||||
|
||||
// Port allocation starts at 3001 to avoid conflicts with common dev ports
|
||||
const BASE_PORT = 3001;
|
||||
const MAX_PORT = 3099; // Safety limit
|
||||
|
||||
// Common livereload ports that may need cleanup when stopping dev servers
|
||||
const LIVERELOAD_PORTS = [35729, 35730, 35731] as const;
|
||||
|
||||
class DevServerService {
|
||||
private runningServers: Map<string, DevServerInfo> = new Map();
|
||||
private allocatedPorts: Set<number> = new Set();
|
||||
private emitter: EventEmitter | null = null;
|
||||
|
||||
/**
|
||||
* Set the event emitter for streaming log events
|
||||
* Called during service initialization with the global event emitter
|
||||
*/
|
||||
setEventEmitter(emitter: EventEmitter): void {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append data to scrollback buffer with size limit enforcement
|
||||
* Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE
|
||||
*/
|
||||
private appendToScrollback(server: DevServerInfo, data: string): void {
|
||||
server.scrollbackBuffer += data;
|
||||
if (server.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
|
||||
server.scrollbackBuffer = server.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffered output to WebSocket subscribers
|
||||
* Sends batched output to prevent overwhelming clients under heavy load
|
||||
*/
|
||||
private flushOutput(server: DevServerInfo): void {
|
||||
// Skip flush if server is stopping or buffer is empty
|
||||
if (server.stopping || server.outputBuffer.length === 0) {
|
||||
server.flushTimeout = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let dataToSend = server.outputBuffer;
|
||||
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
|
||||
// Send in batches if buffer is large
|
||||
dataToSend = server.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
|
||||
server.outputBuffer = server.outputBuffer.slice(OUTPUT_BATCH_SIZE);
|
||||
// Schedule another flush for remaining data
|
||||
server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS);
|
||||
} else {
|
||||
server.outputBuffer = '';
|
||||
server.flushTimeout = null;
|
||||
}
|
||||
|
||||
// Emit output event for WebSocket streaming
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:output', {
|
||||
worktreePath: server.worktreePath,
|
||||
content: dataToSend,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stdout/stderr data from dev server process
|
||||
* Buffers data for scrollback replay and schedules throttled emission
|
||||
*/
|
||||
private handleProcessOutput(server: DevServerInfo, data: Buffer): void {
|
||||
// Skip output if server is stopping
|
||||
if (server.stopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = data.toString();
|
||||
|
||||
// Append to scrollback buffer for replay on reconnect
|
||||
this.appendToScrollback(server, content);
|
||||
|
||||
// Buffer output for throttled live delivery
|
||||
server.outputBuffer += content;
|
||||
|
||||
// Schedule flush if not already scheduled
|
||||
if (!server.flushTimeout) {
|
||||
server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS);
|
||||
}
|
||||
|
||||
// Also log for debugging (existing behavior)
|
||||
logger.debug(`[Port${server.port}] ${content.trim()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is available (not in use by system or by us)
|
||||
@@ -244,10 +343,9 @@ class DevServerService {
|
||||
// Reserve the port (port was already force-killed in findAvailablePort)
|
||||
this.allocatedPorts.add(port);
|
||||
|
||||
// Also kill common related ports (livereload uses 35729 by default)
|
||||
// Also kill common related ports (livereload, etc.)
|
||||
// Some dev servers use fixed ports for HMR/livereload regardless of main port
|
||||
const commonRelatedPorts = [35729, 35730, 35731];
|
||||
for (const relatedPort of commonRelatedPorts) {
|
||||
for (const relatedPort of LIVERELOAD_PORTS) {
|
||||
this.killProcessOnPort(relatedPort);
|
||||
}
|
||||
|
||||
@@ -259,9 +357,14 @@ class DevServerService {
|
||||
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
|
||||
|
||||
// Spawn the dev process with PORT environment variable
|
||||
// FORCE_COLOR enables colored output even when not running in a TTY
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
FORCE_COLOR: '1',
|
||||
// Some tools use these additional env vars for color detection
|
||||
COLORTERM: 'truecolor',
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
|
||||
const devProcess = spawn(devCommand.cmd, devCommand.args, {
|
||||
@@ -274,32 +377,66 @@ class DevServerService {
|
||||
// Track if process failed early using object to work around TypeScript narrowing
|
||||
const status = { error: null as string | null, exited: false };
|
||||
|
||||
// Log output for debugging
|
||||
// Create server info early so we can reference it in handlers
|
||||
// We'll add it to runningServers after verifying the process started successfully
|
||||
const serverInfo: DevServerInfo = {
|
||||
worktreePath,
|
||||
port,
|
||||
url: `http://localhost:${port}`,
|
||||
process: devProcess,
|
||||
startedAt: new Date(),
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
};
|
||||
|
||||
// Capture stdout with buffer management and event emission
|
||||
if (devProcess.stdout) {
|
||||
devProcess.stdout.on('data', (data: Buffer) => {
|
||||
logger.debug(`[Port${port}] ${data.toString().trim()}`);
|
||||
this.handleProcessOutput(serverInfo, data);
|
||||
});
|
||||
}
|
||||
|
||||
// Capture stderr with buffer management and event emission
|
||||
if (devProcess.stderr) {
|
||||
devProcess.stderr.on('data', (data: Buffer) => {
|
||||
const msg = data.toString().trim();
|
||||
logger.debug(`[Port${port}] ${msg}`);
|
||||
this.handleProcessOutput(serverInfo, data);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to clean up resources and emit stop event
|
||||
const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => {
|
||||
if (serverInfo.flushTimeout) {
|
||||
clearTimeout(serverInfo.flushTimeout);
|
||||
serverInfo.flushTimeout = null;
|
||||
}
|
||||
|
||||
// Emit stopped event (only if not already stopping - prevents duplicate events)
|
||||
if (this.emitter && !serverInfo.stopping) {
|
||||
this.emitter.emit('dev-server:stopped', {
|
||||
worktreePath,
|
||||
port,
|
||||
exitCode,
|
||||
error: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
this.allocatedPorts.delete(port);
|
||||
this.runningServers.delete(worktreePath);
|
||||
};
|
||||
|
||||
devProcess.on('error', (error) => {
|
||||
logger.error(`Process error:`, error);
|
||||
status.error = error.message;
|
||||
this.allocatedPorts.delete(port);
|
||||
this.runningServers.delete(worktreePath);
|
||||
cleanupAndEmitStop(null, error.message);
|
||||
});
|
||||
|
||||
devProcess.on('exit', (code) => {
|
||||
logger.info(`Process for ${worktreePath} exited with code ${code}`);
|
||||
status.exited = true;
|
||||
this.allocatedPorts.delete(port);
|
||||
this.runningServers.delete(worktreePath);
|
||||
cleanupAndEmitStop(code);
|
||||
});
|
||||
|
||||
// Wait a moment to see if the process fails immediately
|
||||
@@ -319,16 +456,19 @@ class DevServerService {
|
||||
};
|
||||
}
|
||||
|
||||
const serverInfo: DevServerInfo = {
|
||||
worktreePath,
|
||||
port,
|
||||
url: `http://localhost:${port}`,
|
||||
process: devProcess,
|
||||
startedAt: new Date(),
|
||||
};
|
||||
|
||||
// Server started successfully - add to running servers map
|
||||
this.runningServers.set(worktreePath, serverInfo);
|
||||
|
||||
// Emit started event for WebSocket subscribers
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:started', {
|
||||
worktreePath,
|
||||
port,
|
||||
url: serverInfo.url,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
@@ -365,6 +505,28 @@ class DevServerService {
|
||||
|
||||
logger.info(`Stopping dev server for ${worktreePath}`);
|
||||
|
||||
// Mark as stopping to prevent further output events
|
||||
server.stopping = true;
|
||||
|
||||
// Clean up flush timeout to prevent memory leaks
|
||||
if (server.flushTimeout) {
|
||||
clearTimeout(server.flushTimeout);
|
||||
server.flushTimeout = null;
|
||||
}
|
||||
|
||||
// Clear any pending output buffer
|
||||
server.outputBuffer = '';
|
||||
|
||||
// Emit stopped event immediately so UI updates right away
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:stopped', {
|
||||
worktreePath,
|
||||
port: server.port,
|
||||
exitCode: null, // Will be populated by exit handler if process exits normally
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Kill the process
|
||||
if (server.process && !server.process.killed) {
|
||||
server.process.kill('SIGTERM');
|
||||
@@ -422,6 +584,41 @@ class DevServerService {
|
||||
return this.runningServers.get(worktreePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buffered logs for a worktree's dev server
|
||||
* Returns the scrollback buffer containing historical log output
|
||||
* Used by the API to serve logs to clients on initial connection
|
||||
*/
|
||||
getServerLogs(worktreePath: string): {
|
||||
success: boolean;
|
||||
result?: {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
logs: string;
|
||||
startedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
} {
|
||||
const server = this.runningServers.get(worktreePath);
|
||||
|
||||
if (!server) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No dev server running for worktree: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath: server.worktreePath,
|
||||
port: server.port,
|
||||
logs: server.scrollbackBuffer,
|
||||
startedAt: server.startedAt.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all allocated ports
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,8 @@ describe('claude-provider.ts', () => {
|
||||
vi.clearAllMocks();
|
||||
provider = new ClaudeProvider();
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
});
|
||||
|
||||
describe('getName', () => {
|
||||
@@ -267,6 +269,93 @@ describe('claude-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment variable passthrough', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
});
|
||||
|
||||
it('should pass ANTHROPIC_BASE_URL to SDK env', async () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://custom.example.com/v1';
|
||||
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: 'Test',
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_BASE_URL: 'https://custom.example.com/v1',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass ANTHROPIC_AUTH_TOKEN to SDK env', async () => {
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'custom-auth-token';
|
||||
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: 'Test',
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_AUTH_TOKEN: 'custom-auth-token',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass both custom endpoint vars together', async () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://gateway.example.com';
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'gateway-token';
|
||||
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: 'Test',
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example.com',
|
||||
ANTHROPIC_AUTH_TOKEN: 'gateway-token',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
it('should return 4 Claude models', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
@@ -551,7 +551,7 @@ Resets in 2h
|
||||
expect(result.sessionPercentage).toBe(35);
|
||||
expect(pty.spawn).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
['/c', 'claude', '/usage'],
|
||||
['/c', 'claude', '--add-dir', 'C:\\Users\\testuser'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
@@ -582,8 +582,8 @@ Resets in 2h
|
||||
// Simulate seeing usage data
|
||||
dataCallback!(mockOutput);
|
||||
|
||||
// Advance time to trigger escape key sending
|
||||
vi.advanceTimersByTime(2100);
|
||||
// Advance time to trigger escape key sending (impl uses 3000ms delay)
|
||||
vi.advanceTimersByTime(3100);
|
||||
|
||||
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
|
||||
|
||||
@@ -614,9 +614,10 @@ Resets in 2h
|
||||
const promise = windowsService.fetchUsageData();
|
||||
|
||||
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 () => {
|
||||
@@ -628,14 +629,18 @@ Resets in 2h
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
};
|
||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||
|
||||
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();
|
||||
|
||||
vi.useRealTimers();
|
||||
@@ -654,6 +659,7 @@ Resets in 2h
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
};
|
||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||
|
||||
@@ -662,8 +668,8 @@ Resets in 2h
|
||||
// Simulate receiving usage data
|
||||
dataCallback!('Current session\n65% left\nResets in 2h');
|
||||
|
||||
// Advance time past timeout (30 seconds)
|
||||
vi.advanceTimersByTime(31000);
|
||||
// Advance time past timeout (45 seconds)
|
||||
vi.advanceTimersByTime(46000);
|
||||
|
||||
// Should resolve with data instead of rejecting
|
||||
const result = await promise;
|
||||
@@ -686,6 +692,7 @@ Resets in 2h
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
};
|
||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||
|
||||
@@ -694,8 +701,8 @@ Resets in 2h
|
||||
// Simulate seeing usage data
|
||||
dataCallback!('Current session\n65% left');
|
||||
|
||||
// Advance 2s to trigger ESC
|
||||
vi.advanceTimersByTime(2100);
|
||||
// Advance 3s to trigger ESC (impl uses 3000ms delay)
|
||||
vi.advanceTimersByTime(3100);
|
||||
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
|
||||
|
||||
// Advance another 2s to trigger SIGTERM fallback
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/ui",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||
"repository": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { router } from './utils/router';
|
||||
import { SplashScreen } from './components/splash-screen';
|
||||
import { useSettingsSync } from './hooks/use-settings-sync';
|
||||
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
||||
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
|
||||
import './styles/global.css';
|
||||
import './styles/theme-imports';
|
||||
|
||||
@@ -24,8 +25,11 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
const clearPerfEntries = () => {
|
||||
performance.clearMarks();
|
||||
performance.clearMeasures();
|
||||
// Check if window.performance is available before calling its methods
|
||||
if (window.performance) {
|
||||
window.performance.clearMarks();
|
||||
window.performance.clearMeasures();
|
||||
}
|
||||
};
|
||||
const interval = setInterval(clearPerfEntries, 5000);
|
||||
return () => clearInterval(interval);
|
||||
@@ -45,6 +49,9 @@ export default function App() {
|
||||
// Initialize Cursor CLI status at startup
|
||||
useCursorStatusInit();
|
||||
|
||||
// Initialize Provider auth status at startup (for Claude/Codex usage display)
|
||||
useProviderAuthInit();
|
||||
|
||||
const handleSplashComplete = useCallback(() => {
|
||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||
setShowSplash(false);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useSetupStore } from '@/store/setup-store';
|
||||
const ERROR_CODES = {
|
||||
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
|
||||
AUTH_ERROR: 'AUTH_ERROR',
|
||||
TRUST_PROMPT: 'TRUST_PROMPT',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const;
|
||||
|
||||
@@ -55,8 +56,12 @@ export function ClaudeUsagePopover() {
|
||||
}
|
||||
const data = await api.claude.getUsage();
|
||||
if ('error' in data) {
|
||||
// Detect trust prompt error
|
||||
const isTrustPrompt =
|
||||
data.error === 'Trust prompt pending' ||
|
||||
(data.message && data.message.includes('folder permission'));
|
||||
setError({
|
||||
code: ERROR_CODES.AUTH_ERROR,
|
||||
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
|
||||
message: data.message || data.error,
|
||||
});
|
||||
return;
|
||||
@@ -257,6 +262,11 @@ export function ClaudeUsagePopover() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
||||
'Ensure the Electron bridge is running or restart the app'
|
||||
) : error.code === ERROR_CODES.TRUST_PROMPT ? (
|
||||
<>
|
||||
Run <code className="font-mono bg-muted px-1 rounded">claude</code> in your
|
||||
terminal and approve access to continue
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Make sure Claude CLI is installed and authenticated via{' '}
|
||||
|
||||
@@ -10,43 +10,434 @@ interface IconPickerProps {
|
||||
onSelectIcon: (icon: string | null) => void;
|
||||
}
|
||||
|
||||
// Popular project-related icons
|
||||
// Comprehensive list of project-related icons from Lucide
|
||||
// Organized by category for easier browsing
|
||||
const POPULAR_ICONS = [
|
||||
// Folders & Files
|
||||
'Folder',
|
||||
'FolderOpen',
|
||||
'FolderCode',
|
||||
'FolderGit',
|
||||
'FolderKanban',
|
||||
'Package',
|
||||
'Box',
|
||||
'Boxes',
|
||||
'FolderTree',
|
||||
'FolderInput',
|
||||
'FolderOutput',
|
||||
'FolderPlus',
|
||||
'File',
|
||||
'FileCode',
|
||||
'FileText',
|
||||
'FileJson',
|
||||
'FileImage',
|
||||
'FileVideo',
|
||||
'FileAudio',
|
||||
'FileSpreadsheet',
|
||||
'Files',
|
||||
'Archive',
|
||||
|
||||
// Code & Development
|
||||
'Code',
|
||||
'Code2',
|
||||
'Braces',
|
||||
'FileCode',
|
||||
'Brackets',
|
||||
'Terminal',
|
||||
'Globe',
|
||||
'Server',
|
||||
'Database',
|
||||
'TerminalSquare',
|
||||
'Command',
|
||||
'GitBranch',
|
||||
'GitCommit',
|
||||
'GitMerge',
|
||||
'GitPullRequest',
|
||||
'GitCompare',
|
||||
'GitFork',
|
||||
'GitHub',
|
||||
'Gitlab',
|
||||
'Bitbucket',
|
||||
'Vscode',
|
||||
|
||||
// Packages & Containers
|
||||
'Package',
|
||||
'PackageSearch',
|
||||
'PackageCheck',
|
||||
'PackageX',
|
||||
'Box',
|
||||
'Boxes',
|
||||
'Container',
|
||||
|
||||
// UI & Design
|
||||
'Layout',
|
||||
'LayoutGrid',
|
||||
'LayoutList',
|
||||
'LayoutDashboard',
|
||||
'LayoutTemplate',
|
||||
'Layers',
|
||||
'Layers2',
|
||||
'Layers3',
|
||||
'Blocks',
|
||||
'Component',
|
||||
'Puzzle',
|
||||
'Palette',
|
||||
'Paintbrush',
|
||||
'Brush',
|
||||
'PenTool',
|
||||
'Ruler',
|
||||
'Grid',
|
||||
'Grid3x3',
|
||||
'Square',
|
||||
'RectangleHorizontal',
|
||||
'RectangleVertical',
|
||||
'Circle',
|
||||
|
||||
// Tools & Settings
|
||||
'Cog',
|
||||
'Settings',
|
||||
'Settings2',
|
||||
'Wrench',
|
||||
'Hammer',
|
||||
'Screwdriver',
|
||||
'WrenchIcon',
|
||||
'Tool',
|
||||
'ScrewdriverWrench',
|
||||
'Sliders',
|
||||
'SlidersHorizontal',
|
||||
'Filter',
|
||||
'FilterX',
|
||||
|
||||
// Technology & Infrastructure
|
||||
'Server',
|
||||
'ServerCrash',
|
||||
'ServerCog',
|
||||
'Database',
|
||||
'DatabaseBackup',
|
||||
'CloudUpload',
|
||||
'CloudDownload',
|
||||
'CloudOff',
|
||||
'Globe',
|
||||
'Globe2',
|
||||
'Network',
|
||||
'Wifi',
|
||||
'WifiOff',
|
||||
'Router',
|
||||
'Cpu',
|
||||
'MemoryStick',
|
||||
'HardDrive',
|
||||
'HardDriveIcon',
|
||||
'CircuitBoard',
|
||||
'Microchip',
|
||||
'Monitor',
|
||||
'MonitorSpeaker',
|
||||
'Laptop',
|
||||
'Smartphone',
|
||||
'Tablet',
|
||||
'Mouse',
|
||||
'Keyboard',
|
||||
'Headphones',
|
||||
'Printer',
|
||||
'Scanner',
|
||||
|
||||
// Workflow & Process
|
||||
'Workflow',
|
||||
'Zap',
|
||||
'Rocket',
|
||||
'Sparkles',
|
||||
'Star',
|
||||
'Heart',
|
||||
'Flame',
|
||||
'Lightning',
|
||||
'Bolt',
|
||||
'Target',
|
||||
'Flag',
|
||||
'FlagTriangleRight',
|
||||
'CheckCircle',
|
||||
'CheckCircle2',
|
||||
'XCircle',
|
||||
'AlertCircle',
|
||||
'Info',
|
||||
'HelpCircle',
|
||||
'Clock',
|
||||
'Timer',
|
||||
'Stopwatch',
|
||||
'Calendar',
|
||||
'CalendarDays',
|
||||
'CalendarCheck',
|
||||
'CalendarClock',
|
||||
|
||||
// Security & Access
|
||||
'Shield',
|
||||
'ShieldCheck',
|
||||
'ShieldAlert',
|
||||
'ShieldOff',
|
||||
'Lock',
|
||||
'Unlock',
|
||||
'Key',
|
||||
'Cpu',
|
||||
'CircuitBoard',
|
||||
'Workflow',
|
||||
'KeyRound',
|
||||
'Eye',
|
||||
'EyeOff',
|
||||
'User',
|
||||
'Users',
|
||||
'UserCheck',
|
||||
'UserX',
|
||||
'UserPlus',
|
||||
'UserCog',
|
||||
|
||||
// Business & Finance
|
||||
'Briefcase',
|
||||
'Building',
|
||||
'Building2',
|
||||
'Store',
|
||||
'ShoppingCart',
|
||||
'ShoppingBag',
|
||||
'CreditCard',
|
||||
'Wallet',
|
||||
'DollarSign',
|
||||
'Euro',
|
||||
'PoundSterling',
|
||||
'Yen',
|
||||
'Coins',
|
||||
'Receipt',
|
||||
'ChartBar',
|
||||
'ChartLine',
|
||||
'ChartPie',
|
||||
'TrendingUp',
|
||||
'TrendingDown',
|
||||
'Activity',
|
||||
'BarChart',
|
||||
'LineChart',
|
||||
'PieChart',
|
||||
|
||||
// Communication & Media
|
||||
'MessageSquare',
|
||||
'MessageCircle',
|
||||
'Mail',
|
||||
'MailOpen',
|
||||
'Send',
|
||||
'Inbox',
|
||||
'Phone',
|
||||
'PhoneCall',
|
||||
'Video',
|
||||
'VideoOff',
|
||||
'Camera',
|
||||
'CameraOff',
|
||||
'Image',
|
||||
'ImageIcon',
|
||||
'Film',
|
||||
'Music',
|
||||
'Mic',
|
||||
'MicOff',
|
||||
'Volume',
|
||||
'Volume2',
|
||||
'VolumeX',
|
||||
'Radio',
|
||||
'Podcast',
|
||||
|
||||
// Social & Community
|
||||
'Heart',
|
||||
'HeartHandshake',
|
||||
'Star',
|
||||
'StarOff',
|
||||
'ThumbsUp',
|
||||
'ThumbsDown',
|
||||
'Share',
|
||||
'Share2',
|
||||
'Link',
|
||||
'Link2',
|
||||
'ExternalLink',
|
||||
'AtSign',
|
||||
'Hash',
|
||||
'Hashtag',
|
||||
'Tag',
|
||||
'Tags',
|
||||
|
||||
// Navigation & Location
|
||||
'Compass',
|
||||
'Map',
|
||||
'MapPin',
|
||||
'Navigation',
|
||||
'Navigation2',
|
||||
'Route',
|
||||
'Plane',
|
||||
'Car',
|
||||
'Bike',
|
||||
'Ship',
|
||||
'Train',
|
||||
'Bus',
|
||||
|
||||
// Science & Education
|
||||
'FlaskConical',
|
||||
'FlaskRound',
|
||||
'Beaker',
|
||||
'TestTube',
|
||||
'TestTube2',
|
||||
'Microscope',
|
||||
'Atom',
|
||||
'Brain',
|
||||
'GraduationCap',
|
||||
'Book',
|
||||
'BookOpen',
|
||||
'BookMarked',
|
||||
'Library',
|
||||
'School',
|
||||
'University',
|
||||
|
||||
// Food & Health
|
||||
'Coffee',
|
||||
'Utensils',
|
||||
'UtensilsCrossed',
|
||||
'Apple',
|
||||
'Cherry',
|
||||
'Cookie',
|
||||
'Cake',
|
||||
'Pizza',
|
||||
'Beer',
|
||||
'Wine',
|
||||
'HeartPulse',
|
||||
'Dumbbell',
|
||||
'Running',
|
||||
|
||||
// Nature & Weather
|
||||
'Tree',
|
||||
'TreePine',
|
||||
'Leaf',
|
||||
'Flower',
|
||||
'Flower2',
|
||||
'Sun',
|
||||
'Moon',
|
||||
'CloudRain',
|
||||
'CloudSnow',
|
||||
'CloudLightning',
|
||||
'Droplet',
|
||||
'Wind',
|
||||
'Snowflake',
|
||||
'Umbrella',
|
||||
|
||||
// Objects & Symbols
|
||||
'Puzzle',
|
||||
'PuzzleIcon',
|
||||
'Gamepad',
|
||||
'Gamepad2',
|
||||
'Dice',
|
||||
'Dice1',
|
||||
'Dice6',
|
||||
'Gem',
|
||||
'Crown',
|
||||
'Trophy',
|
||||
'Medal',
|
||||
'Award',
|
||||
'Gift',
|
||||
'GiftIcon',
|
||||
'Bell',
|
||||
'BellOff',
|
||||
'BellRing',
|
||||
'Home',
|
||||
'House',
|
||||
'DoorOpen',
|
||||
'DoorClosed',
|
||||
'Window',
|
||||
'Lightbulb',
|
||||
'LightbulbOff',
|
||||
'Candle',
|
||||
'Flashlight',
|
||||
'FlashlightOff',
|
||||
'Battery',
|
||||
'BatteryFull',
|
||||
'BatteryLow',
|
||||
'BatteryCharging',
|
||||
'Plug',
|
||||
'PlugZap',
|
||||
'Power',
|
||||
'PowerOff',
|
||||
|
||||
// Arrows & Directions
|
||||
'ArrowRight',
|
||||
'ArrowLeft',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowUpRight',
|
||||
'ArrowDownRight',
|
||||
'ArrowDownLeft',
|
||||
'ArrowUpLeft',
|
||||
'ChevronRight',
|
||||
'ChevronLeft',
|
||||
'ChevronUp',
|
||||
'ChevronDown',
|
||||
'Move',
|
||||
'MoveUp',
|
||||
'MoveDown',
|
||||
'MoveLeft',
|
||||
'MoveRight',
|
||||
'RotateCw',
|
||||
'RotateCcw',
|
||||
'RefreshCw',
|
||||
'RefreshCcw',
|
||||
|
||||
// Shapes & Symbols
|
||||
'Diamond',
|
||||
'Pentagon',
|
||||
'Cross',
|
||||
'Plus',
|
||||
'Minus',
|
||||
'X',
|
||||
'Check',
|
||||
'Divide',
|
||||
'Equal',
|
||||
'Infinity',
|
||||
'Percent',
|
||||
|
||||
// Miscellaneous
|
||||
'Bot',
|
||||
'Wand',
|
||||
'Wand2',
|
||||
'Magic',
|
||||
'Stars',
|
||||
'Comet',
|
||||
'Satellite',
|
||||
'SatelliteDish',
|
||||
'Radar',
|
||||
'RadarIcon',
|
||||
'Scan',
|
||||
'ScanLine',
|
||||
'QrCode',
|
||||
'Barcode',
|
||||
'ScanSearch',
|
||||
'Search',
|
||||
'SearchX',
|
||||
'ZoomIn',
|
||||
'ZoomOut',
|
||||
'Maximize',
|
||||
'Minimize',
|
||||
'Maximize2',
|
||||
'Minimize2',
|
||||
'Expand',
|
||||
'Shrink',
|
||||
'Copy',
|
||||
'CopyCheck',
|
||||
'Clipboard',
|
||||
'ClipboardCheck',
|
||||
'ClipboardCopy',
|
||||
'ClipboardList',
|
||||
'ClipboardPaste',
|
||||
'Scissors',
|
||||
'Cut',
|
||||
'FileEdit',
|
||||
'Pen',
|
||||
'Pencil',
|
||||
'Eraser',
|
||||
'Trash',
|
||||
'Trash2',
|
||||
'Delete',
|
||||
'ArchiveRestore',
|
||||
'Download',
|
||||
'Upload',
|
||||
'Save',
|
||||
'SaveAll',
|
||||
'FilePlus',
|
||||
'FileMinus',
|
||||
'FileX',
|
||||
'FileCheck',
|
||||
'FileQuestion',
|
||||
'FileWarning',
|
||||
'FileSearch',
|
||||
'FolderSearch',
|
||||
'FolderX',
|
||||
'FolderCheck',
|
||||
'FolderMinus',
|
||||
'FolderSync',
|
||||
'FolderUp',
|
||||
'FolderDown',
|
||||
];
|
||||
|
||||
export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) {
|
||||
@@ -94,7 +485,7 @@ export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) {
|
||||
)}
|
||||
|
||||
{/* Icons Grid */}
|
||||
<ScrollArea className="h-64 rounded-md border">
|
||||
<ScrollArea className="h-96 rounded-md border">
|
||||
<div className="grid grid-cols-6 gap-1 p-2">
|
||||
{filteredIcons.map((iconName) => {
|
||||
const IconComponent = getIconComponent(iconName);
|
||||
|
||||
@@ -1,8 +1,105 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Edit2, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, memo } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { type ThemeMode, useAppStore } from '@/store/app-store';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants';
|
||||
import { useThemePreview } from '@/components/layout/sidebar/hooks';
|
||||
|
||||
// Constants for z-index values
|
||||
const Z_INDEX = {
|
||||
CONTEXT_MENU: 100,
|
||||
THEME_SUBMENU: 101,
|
||||
} as const;
|
||||
|
||||
// Theme option type - using ThemeMode for type safety
|
||||
interface ThemeOption {
|
||||
value: ThemeMode;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Reusable theme button component to avoid duplication (DRY principle)
|
||||
interface ThemeButtonProps {
|
||||
option: ThemeOption;
|
||||
isSelected: boolean;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: (e: React.PointerEvent) => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ThemeButton = memo(function ThemeButton({
|
||||
option,
|
||||
isSelected,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
onClick,
|
||||
}: ThemeButtonProps) {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md',
|
||||
'text-xs text-left',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:bg-accent',
|
||||
isSelected && 'bg-accent'
|
||||
)}
|
||||
data-testid={`project-theme-${option.value}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" style={{ color: option.color }} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// Reusable theme column component
|
||||
interface ThemeColumnProps {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
themes: ThemeOption[];
|
||||
selectedTheme: ThemeMode | null;
|
||||
onPreviewEnter: (value: ThemeMode) => void;
|
||||
onPreviewLeave: (e: React.PointerEvent) => void;
|
||||
onSelect: (value: ThemeMode) => void;
|
||||
}
|
||||
|
||||
const ThemeColumn = memo(function ThemeColumn({
|
||||
title,
|
||||
icon: Icon,
|
||||
themes,
|
||||
selectedTheme,
|
||||
onPreviewEnter,
|
||||
onPreviewLeave,
|
||||
onSelect,
|
||||
}: ThemeColumnProps) {
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Icon className="w-3 h-3" />
|
||||
{title}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{themes.map((option) => (
|
||||
<ThemeButton
|
||||
key={option.value}
|
||||
option={option}
|
||||
isSelected={selectedTheme === option.value}
|
||||
onPointerEnter={() => onPreviewEnter(option.value)}
|
||||
onPointerLeave={onPreviewLeave}
|
||||
onClick={() => onSelect(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ProjectContextMenuProps {
|
||||
project: Project;
|
||||
@@ -18,17 +115,30 @@ export function ProjectContextMenu({
|
||||
onEdit,
|
||||
}: ProjectContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { moveProjectToTrash } = useAppStore();
|
||||
const {
|
||||
moveProjectToTrash,
|
||||
theme: globalTheme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
setPreviewTheme,
|
||||
} = useAppStore();
|
||||
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
|
||||
const themeSubmenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setPreviewTheme(null);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setPreviewTheme(null);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
@@ -40,64 +150,184 @@ export function ProjectContextMenu({
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
}, [onClose, setPreviewTheme]);
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit(project);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
if (confirm(`Remove "${project.name}" from the project list?`)) {
|
||||
moveProjectToTrash(project.id);
|
||||
setShowRemoveDialog(true);
|
||||
};
|
||||
|
||||
const handleThemeSelect = (value: ThemeMode | '') => {
|
||||
setPreviewTheme(null);
|
||||
if (value !== '') {
|
||||
setTheme(value);
|
||||
} else {
|
||||
setTheme(globalTheme);
|
||||
}
|
||||
setProjectTheme(project.id, value === '' ? null : value);
|
||||
setShowThemeSubmenu(false);
|
||||
};
|
||||
|
||||
const handleConfirmRemove = () => {
|
||||
moveProjectToTrash(project.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
'fixed z-[100] min-w-48 rounded-lg',
|
||||
'bg-popover text-popover-foreground',
|
||||
'border border-border shadow-lg',
|
||||
'animate-in fade-in zoom-in-95 duration-100'
|
||||
)}
|
||||
style={{
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
}}
|
||||
data-testid="project-context-menu"
|
||||
>
|
||||
<div className="p-1">
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||
'text-sm font-medium text-left',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:bg-accent'
|
||||
)}
|
||||
data-testid="edit-project-button"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
<span>Edit Name & Icon</span>
|
||||
</button>
|
||||
<>
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
'fixed min-w-48 rounded-lg',
|
||||
'bg-popover text-popover-foreground',
|
||||
'border border-border shadow-lg',
|
||||
'animate-in fade-in zoom-in-95 duration-100'
|
||||
)}
|
||||
style={{
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
zIndex: Z_INDEX.CONTEXT_MENU,
|
||||
}}
|
||||
data-testid="project-context-menu"
|
||||
>
|
||||
<div className="p-1">
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||
'text-sm font-medium text-left',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:bg-accent'
|
||||
)}
|
||||
data-testid="edit-project-button"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
<span>Edit Name & Icon</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||
'text-sm font-medium text-left',
|
||||
'text-destructive hover:bg-destructive/10',
|
||||
'transition-colors',
|
||||
'focus:outline-none focus:bg-destructive/10'
|
||||
)}
|
||||
data-testid="remove-project-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Remove Project</span>
|
||||
</button>
|
||||
{/* Theme Submenu Trigger */}
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setShowThemeSubmenu(true)}
|
||||
onMouseLeave={() => {
|
||||
setShowThemeSubmenu(false);
|
||||
setPreviewTheme(null);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||
'text-sm font-medium text-left',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:bg-accent'
|
||||
)}
|
||||
data-testid="theme-project-button"
|
||||
>
|
||||
<Palette className="w-4 h-4" />
|
||||
<span className="flex-1">Project Theme</span>
|
||||
{project.theme && (
|
||||
<span className="text-[10px] text-muted-foreground capitalize">
|
||||
{project.theme}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Theme Submenu */}
|
||||
{showThemeSubmenu && (
|
||||
<div
|
||||
ref={themeSubmenuRef}
|
||||
className={cn(
|
||||
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg',
|
||||
'bg-popover text-popover-foreground',
|
||||
'border border-border shadow-lg',
|
||||
'animate-in fade-in zoom-in-95 duration-100'
|
||||
)}
|
||||
style={{ zIndex: Z_INDEX.THEME_SUBMENU }}
|
||||
data-testid="project-theme-submenu"
|
||||
>
|
||||
<div className="p-2">
|
||||
{/* Use Global Option */}
|
||||
<button
|
||||
onPointerEnter={() => handlePreviewEnter(globalTheme)}
|
||||
onPointerLeave={handlePreviewLeave}
|
||||
onClick={() => handleThemeSelect('')}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||
'text-sm font-medium text-left',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:bg-accent',
|
||||
!project.theme && 'bg-accent'
|
||||
)}
|
||||
data-testid="project-theme-global"
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span>Use Global</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
|
||||
({globalTheme})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-border my-2" />
|
||||
|
||||
{/* Two Column Layout - Using reusable ThemeColumn component */}
|
||||
<div className="flex gap-2">
|
||||
<ThemeColumn
|
||||
title="Dark"
|
||||
icon={Moon}
|
||||
themes={PROJECT_DARK_THEMES as ThemeOption[]}
|
||||
selectedTheme={project.theme as ThemeMode | null}
|
||||
onPreviewEnter={handlePreviewEnter}
|
||||
onPreviewLeave={handlePreviewLeave}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
<ThemeColumn
|
||||
title="Light"
|
||||
icon={Sun}
|
||||
themes={PROJECT_LIGHT_THEMES as ThemeOption[]}
|
||||
selectedTheme={project.theme as ThemeMode | null}
|
||||
onPreviewEnter={handlePreviewEnter}
|
||||
onPreviewLeave={handlePreviewLeave}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||
'text-sm font-medium text-left',
|
||||
'text-destructive hover:bg-destructive/10',
|
||||
'transition-colors',
|
||||
'focus:outline-none focus:bg-destructive/10'
|
||||
)}
|
||||
data-testid="remove-project-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Remove Project</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showRemoveDialog}
|
||||
onOpenChange={setShowRemoveDialog}
|
||||
onConfirm={handleConfirmRemove}
|
||||
title="Remove Project"
|
||||
description={`Are you sure you want to remove "${project.name}" from the project list? This won't delete any files on disk.`}
|
||||
icon={Trash2}
|
||||
iconClassName="text-destructive"
|
||||
confirmText="Remove"
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Plus, Bug } from 'lucide-react';
|
||||
import { Plus, Bug, FolderOpen } from 'lucide-react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||
import { useOSDetection } from '@/hooks/use-os-detection';
|
||||
import { ProjectSwitcherItem } from './components/project-switcher-item';
|
||||
import { ProjectContextMenu } from './components/project-context-menu';
|
||||
@@ -12,6 +12,9 @@ import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
|
||||
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
||||
|
||||
function getOSAbbreviation(os: string): string {
|
||||
switch (os) {
|
||||
@@ -34,6 +37,8 @@ export function ProjectSwitcher() {
|
||||
setCurrentProject,
|
||||
trashedProjects,
|
||||
upsertAndSetCurrentProject,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
} = useAppStore();
|
||||
const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
|
||||
@@ -41,6 +46,17 @@ export function ProjectSwitcher() {
|
||||
);
|
||||
const [editDialogProject, setEditDialogProject] = useState<Project | null>(null);
|
||||
|
||||
// Setup dialog state for opening existing projects
|
||||
const [showSetupDialog, setShowSetupDialog] = useState(false);
|
||||
const [setupProjectPath, setSetupProjectPath] = useState<string | null>(null);
|
||||
const [projectOverview, setProjectOverview] = useState('');
|
||||
const [generateFeatures, setGenerateFeatures] = useState(true);
|
||||
const [analyzeProject, setAnalyzeProject] = useState(true);
|
||||
const [featureCount, setFeatureCount] = useState(5);
|
||||
|
||||
// Derive isCreatingSpec from store state
|
||||
const isCreatingSpec = specCreatingForProject !== null;
|
||||
|
||||
// Version info
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
|
||||
const { os } = useOSDetection();
|
||||
@@ -108,6 +124,109 @@ export function ProjectSwitcher() {
|
||||
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Opens the system folder selection dialog and initializes the selected project.
|
||||
*/
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const path = result.filePaths[0];
|
||||
// Extract folder name from path (works on both Windows and Mac/Linux)
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
||||
|
||||
try {
|
||||
// Check if this is a brand new project (no .automaker directory)
|
||||
const hadAutomakerDir = await hasAutomakerDir(path);
|
||||
|
||||
// Initialize the .automaker directory structure
|
||||
const initResult = await initializeProject(path);
|
||||
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to initialize project', {
|
||||
description: initResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Upsert project and set as current (handles both create and update cases)
|
||||
// Theme preservation is handled by the store action
|
||||
const trashedProject = trashedProjects.find((p) => p.path === path);
|
||||
const effectiveTheme =
|
||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||
(currentProject?.theme as ThemeMode | undefined) ||
|
||||
globalTheme;
|
||||
upsertAndSetCurrentProject(path, name, effectiveTheme);
|
||||
|
||||
// Check if app_spec.txt exists
|
||||
const specExists = await hasAppSpec(path);
|
||||
|
||||
if (!hadAutomakerDir && !specExists) {
|
||||
// This is a brand new project - show setup dialog
|
||||
setSetupProjectPath(path);
|
||||
setShowSetupDialog(true);
|
||||
toast.success('Project opened', {
|
||||
description: `Opened ${name}. Let's set up your app specification!`,
|
||||
});
|
||||
} else if (initResult.createdFiles && initResult.createdFiles.length > 0) {
|
||||
toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', {
|
||||
description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`,
|
||||
});
|
||||
} else {
|
||||
toast.success('Project opened', {
|
||||
description: `Opened ${name}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Navigate to board view
|
||||
navigate({ to: '/board' });
|
||||
} catch (error) {
|
||||
console.error('Failed to open project:', error);
|
||||
toast.error('Failed to open project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme, navigate]);
|
||||
|
||||
// Handler for creating initial spec from the setup dialog
|
||||
const handleCreateInitialSpec = useCallback(async () => {
|
||||
if (!setupProjectPath) return;
|
||||
|
||||
setSpecCreatingForProject(setupProjectPath);
|
||||
setShowSetupDialog(false);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
await api.generateAppSpec({
|
||||
projectPath: setupProjectPath,
|
||||
projectOverview,
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
featureCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate spec:', error);
|
||||
toast.error('Failed to generate spec', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
setSpecCreatingForProject(null);
|
||||
}
|
||||
}, [
|
||||
setupProjectPath,
|
||||
projectOverview,
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
featureCount,
|
||||
setSpecCreatingForProject,
|
||||
]);
|
||||
|
||||
const handleSkipSetup = useCallback(() => {
|
||||
setShowSetupDialog(false);
|
||||
setSetupProjectPath(null);
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts for project switching (1-9, 0)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -204,7 +323,7 @@ export function ProjectSwitcher() {
|
||||
</div>
|
||||
|
||||
{/* Projects List */}
|
||||
<div className="flex-1 overflow-y-auto py-3 px-2 space-y-2">
|
||||
<div className="flex-1 overflow-y-auto pt-1 pb-3 px-2 space-y-2">
|
||||
{projects.map((project, index) => (
|
||||
<ProjectSwitcherItem
|
||||
key={project.id}
|
||||
@@ -219,7 +338,7 @@ export function ProjectSwitcher() {
|
||||
{/* Horizontal rule and Add Project Button - only show if there are projects */}
|
||||
{projects.length > 0 && (
|
||||
<>
|
||||
<div className="w-full h-px bg-border/40 my-2" />
|
||||
<div className="w-full h-px bg-border my-2" />
|
||||
<button
|
||||
onClick={handleNewProject}
|
||||
className={cn(
|
||||
@@ -234,25 +353,55 @@ export function ProjectSwitcher() {
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenFolder}
|
||||
className={cn(
|
||||
'w-full aspect-square rounded-xl flex items-center justify-center',
|
||||
'transition-all duration-200 ease-out',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'hover:shadow-sm hover:scale-105 active:scale-95'
|
||||
)}
|
||||
title="Open Project"
|
||||
data-testid="open-project-button"
|
||||
>
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add Project Button - when no projects, show without rule */}
|
||||
{projects.length === 0 && (
|
||||
<button
|
||||
onClick={handleNewProject}
|
||||
className={cn(
|
||||
'w-full aspect-square rounded-xl flex items-center justify-center',
|
||||
'transition-all duration-200 ease-out',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'hover:shadow-sm hover:scale-105 active:scale-95'
|
||||
)}
|
||||
title="New Project"
|
||||
data-testid="new-project-button"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={handleNewProject}
|
||||
className={cn(
|
||||
'w-full aspect-square rounded-xl flex items-center justify-center',
|
||||
'transition-all duration-200 ease-out',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'hover:shadow-sm hover:scale-105 active:scale-95'
|
||||
)}
|
||||
title="New Project"
|
||||
data-testid="new-project-button"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenFolder}
|
||||
className={cn(
|
||||
'w-full aspect-square rounded-xl flex items-center justify-center',
|
||||
'transition-all duration-200 ease-out',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'hover:shadow-sm hover:scale-105 active:scale-95'
|
||||
)}
|
||||
title="Open Project"
|
||||
data-testid="open-project-button"
|
||||
>
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -312,6 +461,26 @@ export function ProjectSwitcher() {
|
||||
onSkip={handleOnboardingSkip}
|
||||
onGenerateSpec={handleOnboardingSkip}
|
||||
/>
|
||||
|
||||
{/* Setup Dialog for Open Project */}
|
||||
<CreateSpecDialog
|
||||
open={showSetupDialog}
|
||||
onOpenChange={setShowSetupDialog}
|
||||
projectOverview={projectOverview}
|
||||
onProjectOverviewChange={setProjectOverview}
|
||||
generateFeatures={generateFeatures}
|
||||
onGenerateFeaturesChange={setGenerateFeatures}
|
||||
analyzeProject={analyzeProject}
|
||||
onAnalyzeProjectChange={setAnalyzeProject}
|
||||
featureCount={featureCount}
|
||||
onFeatureCountChange={setFeatureCount}
|
||||
onCreateSpec={handleCreateInitialSpec}
|
||||
onSkip={handleSkipSetup}
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
showSkipButton={true}
|
||||
title="Set Up Your Project"
|
||||
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,26 +253,25 @@ export function Sidebar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay backdrop */}
|
||||
{/* Mobile backdrop overlay */}
|
||||
{sidebarOpen && (
|
||||
<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}
|
||||
aria-hidden="true"
|
||||
data-testid="sidebar-backdrop"
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
className={cn(
|
||||
'flex-shrink-0 flex flex-col z-50 relative',
|
||||
'flex-shrink-0 flex flex-col z-30',
|
||||
// Glass morphism background with gradient
|
||||
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
||||
// Premium border with subtle glow
|
||||
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
||||
// Smooth width transition
|
||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||
// Mobile: hidden when closed, full width overlay when open
|
||||
// Desktop: always visible, toggle between narrow and wide
|
||||
sidebarOpen ? 'fixed lg:relative left-0 top-0 h-full w-72' : 'hidden lg:flex w-16'
|
||||
// Mobile: overlay when open, collapsed when closed
|
||||
sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16'
|
||||
)}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
|
||||
@@ -17,9 +17,7 @@ export function CollapseToggleButton({
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
// Show on desktop always, show on mobile only when sidebar is open
|
||||
sidebarOpen ? 'flex' : 'hidden lg:flex',
|
||||
'absolute top-[68px] -right-3 z-9999',
|
||||
'flex absolute top-[68px] -right-3 z-9999',
|
||||
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
||||
// Glass morphism button
|
||||
'bg-card/95 backdrop-blur-sm border border-border/80',
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ProjectActions({
|
||||
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" />
|
||||
<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
|
||||
onClick={handleOpenFolder}
|
||||
@@ -59,7 +59,7 @@ export function ProjectActions({
|
||||
data-testid="open-project-button"
|
||||
>
|
||||
<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)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -235,7 +235,7 @@ export function SidebarFooter({
|
||||
{sidebarOpen && (
|
||||
<span
|
||||
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')
|
||||
? 'bg-brand-500/20 text-brand-400'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Folder, LucideIcon } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { cn, isMac } from '@/lib/utils';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { isElectron, type Project } from '@/lib/electron';
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
sidebarOpen: boolean;
|
||||
@@ -25,14 +25,17 @@ export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProp
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex flex-col',
|
||||
// Add minimal padding on macOS for traffic light buttons
|
||||
isMac && 'pt-2'
|
||||
// Add padding on macOS Electron for traffic light buttons
|
||||
isMac && isElectron() && 'pt-[10px]'
|
||||
)}
|
||||
>
|
||||
{/* Project name and icon display */}
|
||||
{currentProject && (
|
||||
<div
|
||||
className={cn('flex items-center gap-3 px-4 py-3', !sidebarOpen && 'justify-center px-2')}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 pt-3 pb-1',
|
||||
!sidebarOpen && 'justify-center px-2'
|
||||
)}
|
||||
>
|
||||
{/* Project Icon */}
|
||||
<div className="shrink-0">
|
||||
|
||||
@@ -21,12 +21,12 @@ export function SidebarNavigation({
|
||||
navigate,
|
||||
}: SidebarNavigationProps) {
|
||||
return (
|
||||
<nav className={cn('flex-1 overflow-y-auto px-3 pb-2', sidebarOpen ? 'mt-5' : 'mt-1')}>
|
||||
<nav className={cn('flex-1 overflow-y-auto px-3 pb-2', sidebarOpen ? 'mt-1' : 'mt-1')}>
|
||||
{!currentProject && sidebarOpen ? (
|
||||
// Placeholder when no project is selected (only in expanded state)
|
||||
<div className="flex items-center justify-center h-full px-4">
|
||||
<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>
|
||||
</div>
|
||||
) : currentProject ? (
|
||||
@@ -137,7 +137,7 @@ export function SidebarNavigation({
|
||||
{item.shortcut && sidebarOpen && !item.count && (
|
||||
<span
|
||||
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
|
||||
? 'bg-brand-500/20 text-brand-400'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||
|
||||
@@ -137,6 +137,8 @@ export function Autocomplete({
|
||||
width: Math.max(triggerWidth, 200),
|
||||
}}
|
||||
data-testid={testId ? `${testId}-list` : undefined}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
|
||||
@@ -78,7 +78,14 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
|
||||
return (
|
||||
<CommandPrimitive.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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -230,7 +230,7 @@ export function TaskProgressPanel({
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="p-4 pt-2 relative max-h-[300px] overflow-y-auto scrollbar-visible">
|
||||
<div className="p-4 pt-2 relative max-h-[200px] overflow-y-auto scrollbar-visible">
|
||||
{/* Vertical Connector Line */}
|
||||
<div className="absolute left-[2.35rem] top-4 bottom-8 w-px bg-linear-to-b from-border/80 via-border/40 to-transparent" />
|
||||
|
||||
|
||||
300
apps/ui/src/components/ui/xterm-log-viewer.tsx
Normal file
300
apps/ui/src/components/ui/xterm-log-viewer.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getTerminalTheme, DEFAULT_TERMINAL_FONT } from '@/config/terminal-themes';
|
||||
|
||||
// Types for dynamically imported xterm modules
|
||||
type XTerminal = InstanceType<typeof import('@xterm/xterm').Terminal>;
|
||||
type XFitAddon = InstanceType<typeof import('@xterm/addon-fit').FitAddon>;
|
||||
|
||||
export interface XtermLogViewerRef {
|
||||
/** Append content to the log viewer */
|
||||
append: (content: string) => void;
|
||||
/** Clear all content */
|
||||
clear: () => void;
|
||||
/** Scroll to the bottom */
|
||||
scrollToBottom: () => void;
|
||||
/** Write content (replaces all content) */
|
||||
write: (content: string) => void;
|
||||
}
|
||||
|
||||
export interface XtermLogViewerProps {
|
||||
/** Initial content to display */
|
||||
initialContent?: string;
|
||||
/** Font size in pixels (default: 13) */
|
||||
fontSize?: number;
|
||||
/** Whether to auto-scroll to bottom when new content is added (default: true) */
|
||||
autoScroll?: boolean;
|
||||
/** Custom class name for the container */
|
||||
className?: string;
|
||||
/** Minimum height for the container */
|
||||
minHeight?: number;
|
||||
/** Callback when user scrolls away from bottom */
|
||||
onScrollAwayFromBottom?: () => void;
|
||||
/** Callback when user scrolls to bottom */
|
||||
onScrollToBottom?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only terminal log viewer using xterm.js for perfect ANSI color rendering.
|
||||
* Use this component when you need to display terminal output with ANSI escape codes.
|
||||
*/
|
||||
export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>(
|
||||
(
|
||||
{
|
||||
initialContent,
|
||||
fontSize = 13,
|
||||
autoScroll = true,
|
||||
className,
|
||||
minHeight = 300,
|
||||
onScrollAwayFromBottom,
|
||||
onScrollToBottom,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<XTerminal | null>(null);
|
||||
const fitAddonRef = useRef<XFitAddon | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const autoScrollRef = useRef(autoScroll);
|
||||
const pendingContentRef = useRef<string[]>([]);
|
||||
|
||||
// Get theme from store
|
||||
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
|
||||
const effectiveTheme = getEffectiveTheme();
|
||||
|
||||
// Track system dark mode for "system" theme
|
||||
const [systemIsDark, setSystemIsDark] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (e: MediaQueryListEvent) => setSystemIsDark(e.matches);
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
const resolvedTheme =
|
||||
effectiveTheme === 'system' ? (systemIsDark ? 'dark' : 'light') : effectiveTheme;
|
||||
|
||||
// Update autoScroll ref when prop changes
|
||||
useEffect(() => {
|
||||
autoScrollRef.current = autoScroll;
|
||||
}, [autoScroll]);
|
||||
|
||||
// Initialize xterm
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const initTerminal = async () => {
|
||||
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||
import('@xterm/xterm'),
|
||||
import('@xterm/addon-fit'),
|
||||
]);
|
||||
await import('@xterm/xterm/css/xterm.css');
|
||||
|
||||
if (!mounted || !containerRef.current) return;
|
||||
|
||||
const terminalTheme = getTerminalTheme(resolvedTheme);
|
||||
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'underline',
|
||||
cursorInactiveStyle: 'none',
|
||||
fontSize,
|
||||
fontFamily: DEFAULT_TERMINAL_FONT,
|
||||
lineHeight: 1.2,
|
||||
theme: terminalTheme,
|
||||
disableStdin: true, // Read-only mode
|
||||
scrollback: 10000,
|
||||
convertEol: true,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
terminal.open(containerRef.current);
|
||||
|
||||
// Try to load WebGL addon for better performance
|
||||
try {
|
||||
const { WebglAddon } = await import('@xterm/addon-webgl');
|
||||
const webglAddon = new WebglAddon();
|
||||
webglAddon.onContextLoss(() => webglAddon.dispose());
|
||||
terminal.loadAddon(webglAddon);
|
||||
} catch {
|
||||
// WebGL not available, continue with canvas renderer
|
||||
}
|
||||
|
||||
// Wait for layout to stabilize then fit
|
||||
requestAnimationFrame(() => {
|
||||
if (mounted && containerRef.current) {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch {
|
||||
// Ignore fit errors during initialization
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xtermRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
setIsReady(true);
|
||||
|
||||
// Write initial content if provided
|
||||
if (initialContent) {
|
||||
terminal.write(initialContent);
|
||||
}
|
||||
|
||||
// Write any pending content that was queued before terminal was ready
|
||||
if (pendingContentRef.current.length > 0) {
|
||||
pendingContentRef.current.forEach((content) => terminal.write(content));
|
||||
pendingContentRef.current = [];
|
||||
}
|
||||
};
|
||||
|
||||
initTerminal();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
}
|
||||
fitAddonRef.current = null;
|
||||
setIsReady(false);
|
||||
};
|
||||
}, []); // Only run once on mount
|
||||
|
||||
// Update theme when it changes
|
||||
useEffect(() => {
|
||||
if (xtermRef.current && isReady) {
|
||||
const terminalTheme = getTerminalTheme(resolvedTheme);
|
||||
xtermRef.current.options.theme = terminalTheme;
|
||||
}
|
||||
}, [resolvedTheme, isReady]);
|
||||
|
||||
// Update font size when it changes
|
||||
useEffect(() => {
|
||||
if (xtermRef.current && isReady) {
|
||||
xtermRef.current.options.fontSize = fontSize;
|
||||
fitAddonRef.current?.fit();
|
||||
}
|
||||
}, [fontSize, isReady]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !isReady) return;
|
||||
|
||||
const handleResize = () => {
|
||||
if (fitAddonRef.current && containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(containerRef.current);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [isReady]);
|
||||
|
||||
// Monitor scroll position
|
||||
useEffect(() => {
|
||||
if (!isReady || !containerRef.current) return;
|
||||
|
||||
const viewport = containerRef.current.querySelector('.xterm-viewport') as HTMLElement | null;
|
||||
if (!viewport) return;
|
||||
|
||||
const checkScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = viewport;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight <= 10;
|
||||
|
||||
if (isAtBottom) {
|
||||
autoScrollRef.current = true;
|
||||
onScrollToBottom?.();
|
||||
} else {
|
||||
autoScrollRef.current = false;
|
||||
onScrollAwayFromBottom?.();
|
||||
}
|
||||
};
|
||||
|
||||
viewport.addEventListener('scroll', checkScroll, { passive: true });
|
||||
return () => viewport.removeEventListener('scroll', checkScroll);
|
||||
}, [isReady, onScrollAwayFromBottom, onScrollToBottom]);
|
||||
|
||||
// Expose methods via ref
|
||||
const append = useCallback((content: string) => {
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.write(content);
|
||||
if (autoScrollRef.current) {
|
||||
xtermRef.current.scrollToBottom();
|
||||
}
|
||||
} else {
|
||||
// Queue content if terminal isn't ready yet
|
||||
pendingContentRef.current.push(content);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.clear();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.scrollToBottom();
|
||||
autoScrollRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const write = useCallback((content: string) => {
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.reset();
|
||||
xtermRef.current.write(content);
|
||||
if (autoScrollRef.current) {
|
||||
xtermRef.current.scrollToBottom();
|
||||
}
|
||||
} else {
|
||||
pendingContentRef.current = [content];
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
append,
|
||||
clear,
|
||||
scrollToBottom,
|
||||
write,
|
||||
}));
|
||||
|
||||
const terminalTheme = getTerminalTheme(resolvedTheme);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{
|
||||
minHeight,
|
||||
backgroundColor: terminalTheme.background,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
XtermLogViewer.displayName = 'XtermLogViewer';
|
||||
@@ -14,6 +14,7 @@ const ERROR_CODES = {
|
||||
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
|
||||
AUTH_ERROR: 'AUTH_ERROR',
|
||||
NOT_AVAILABLE: 'NOT_AVAILABLE',
|
||||
TRUST_PROMPT: 'TRUST_PROMPT',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const;
|
||||
|
||||
@@ -72,18 +73,17 @@ export function UsagePopover() {
|
||||
const [codexError, setCodexError] = useState<UsageError | null>(null);
|
||||
|
||||
// Check authentication status
|
||||
const isClaudeCliVerified =
|
||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
||||
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
||||
|
||||
// Determine which tab to show by default
|
||||
useEffect(() => {
|
||||
if (isClaudeCliVerified) {
|
||||
if (isClaudeAuthenticated) {
|
||||
setActiveTab('claude');
|
||||
} else if (isCodexAuthenticated) {
|
||||
setActiveTab('codex');
|
||||
}
|
||||
}, [isClaudeCliVerified, isCodexAuthenticated]);
|
||||
}, [isClaudeAuthenticated, isCodexAuthenticated]);
|
||||
|
||||
// Check if data is stale (older than 2 minutes)
|
||||
const isClaudeStale = useMemo(() => {
|
||||
@@ -109,8 +109,12 @@ export function UsagePopover() {
|
||||
}
|
||||
const data = await api.claude.getUsage();
|
||||
if ('error' in data) {
|
||||
// Detect trust prompt error
|
||||
const isTrustPrompt =
|
||||
data.error === 'Trust prompt pending' ||
|
||||
(data.message && data.message.includes('folder permission'));
|
||||
setClaudeError({
|
||||
code: ERROR_CODES.AUTH_ERROR,
|
||||
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
|
||||
message: data.message || data.error,
|
||||
});
|
||||
return;
|
||||
@@ -174,10 +178,10 @@ export function UsagePopover() {
|
||||
|
||||
// Auto-fetch on mount if data is stale
|
||||
useEffect(() => {
|
||||
if (isClaudeStale && isClaudeCliVerified) {
|
||||
if (isClaudeStale && isClaudeAuthenticated) {
|
||||
fetchClaudeUsage(true);
|
||||
}
|
||||
}, [isClaudeStale, isClaudeCliVerified, fetchClaudeUsage]);
|
||||
}, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCodexStale && isCodexAuthenticated) {
|
||||
@@ -190,7 +194,7 @@ export function UsagePopover() {
|
||||
if (!open) return;
|
||||
|
||||
// Fetch based on active tab
|
||||
if (activeTab === 'claude' && isClaudeCliVerified) {
|
||||
if (activeTab === 'claude' && isClaudeAuthenticated) {
|
||||
if (!claudeUsage || isClaudeStale) {
|
||||
fetchClaudeUsage();
|
||||
}
|
||||
@@ -214,7 +218,7 @@ export function UsagePopover() {
|
||||
activeTab,
|
||||
claudeUsage,
|
||||
isClaudeStale,
|
||||
isClaudeCliVerified,
|
||||
isClaudeAuthenticated,
|
||||
codexUsage,
|
||||
isCodexStale,
|
||||
isCodexAuthenticated,
|
||||
@@ -349,7 +353,7 @@ export function UsagePopover() {
|
||||
);
|
||||
|
||||
// Determine which tabs to show
|
||||
const showClaudeTab = isClaudeCliVerified;
|
||||
const showClaudeTab = isClaudeAuthenticated;
|
||||
const showCodexTab = isCodexAuthenticated;
|
||||
|
||||
return (
|
||||
@@ -405,6 +409,11 @@ export function UsagePopover() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{claudeError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
||||
'Ensure the Electron bridge is running or restart the app'
|
||||
) : claudeError.code === ERROR_CODES.TRUST_PROMPT ? (
|
||||
<>
|
||||
Run <code className="font-mono bg-muted px-1 rounded">claude</code> in
|
||||
your terminal and approve access to continue
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Make sure Claude CLI is installed and authenticated via{' '}
|
||||
|
||||
@@ -16,11 +16,32 @@ import {
|
||||
import { NoProjectState, AgentHeader, ChatArea } from './agent-view/components';
|
||||
import { AgentInputArea } from './agent-view/input-area';
|
||||
|
||||
/** Tailwind lg breakpoint in pixels */
|
||||
const LG_BREAKPOINT = 1024;
|
||||
|
||||
export function AgentView() {
|
||||
const { currentProject } = useAppStore();
|
||||
const [input, setInput] = useState('');
|
||||
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);
|
||||
|
||||
// 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' });
|
||||
|
||||
// Input ref for auto-focus
|
||||
@@ -119,6 +140,13 @@ export function AgentView() {
|
||||
}
|
||||
}, [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
|
||||
const displayMessages =
|
||||
messages.length === 0
|
||||
@@ -139,9 +167,18 @@ export function AgentView() {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
{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
|
||||
currentSessionId={currentSessionId}
|
||||
onSelectSession={handleSelectSession}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function InputControls({
|
||||
{/* Text Input and Controls */}
|
||||
<div
|
||||
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'
|
||||
)}
|
||||
onDragEnter={onDragEnter}
|
||||
@@ -87,7 +87,8 @@ export function InputControls({
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<div className="flex-1 relative">
|
||||
{/* Textarea - full width on mobile */}
|
||||
<div className="relative w-full">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
placeholder={
|
||||
@@ -105,14 +106,14 @@ export function InputControls({
|
||||
data-testid="agent-input"
|
||||
rows={1}
|
||||
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',
|
||||
hasFiles && 'border-primary/30',
|
||||
isDragOver && 'border-primary bg-primary/5'
|
||||
)}
|
||||
/>
|
||||
{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
|
||||
</div>
|
||||
)}
|
||||
@@ -124,58 +125,64 @@ export function InputControls({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Selector */}
|
||||
<AgentModelSelector
|
||||
value={modelSelection}
|
||||
onChange={onModelSelect}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
{/* Controls row - responsive layout */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Model Selector */}
|
||||
<AgentModelSelector
|
||||
value={modelSelection}
|
||||
onChange={onModelSelect}
|
||||
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 */}
|
||||
<Button
|
||||
onClick={onSend}
|
||||
disabled={!canSend}
|
||||
className="h-11 px-4 rounded-xl"
|
||||
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>
|
||||
{/* File Attachment Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onToggleImageDropZone}
|
||||
disabled={!isConnected}
|
||||
className={cn(
|
||||
'h-11 w-11 rounded-xl border-border shrink-0',
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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
|
||||
send,{' '}
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Shift+Enter</kbd>{' '}
|
||||
|
||||
@@ -58,9 +58,10 @@ import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialo
|
||||
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
|
||||
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
|
||||
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
|
||||
import { MergeWorktreeDialog } from './board-view/dialogs/merge-worktree-dialog';
|
||||
import { WorktreePanel } from './board-view/worktree-panel';
|
||||
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
|
||||
import { COLUMNS } from './board-view/constants';
|
||||
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
|
||||
import {
|
||||
useBoardFeatures,
|
||||
useBoardDragDrop,
|
||||
@@ -72,8 +73,9 @@ import {
|
||||
useBoardPersistence,
|
||||
useFollowUpState,
|
||||
useSelectionMode,
|
||||
useListViewState,
|
||||
} from './board-view/hooks';
|
||||
import { SelectionActionBar } from './board-view/components';
|
||||
import { SelectionActionBar, ListView } from './board-view/components';
|
||||
import { MassEditDialog } from './board-view/dialogs';
|
||||
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
||||
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
||||
@@ -147,6 +149,7 @@ export function BoardView() {
|
||||
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
|
||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||
const [showMergeWorktreeDialog, setShowMergeWorktreeDialog] = useState(false);
|
||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
|
||||
path: string;
|
||||
branch: string;
|
||||
@@ -194,6 +197,9 @@ export function BoardView() {
|
||||
} = useSelectionMode();
|
||||
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
|
||||
|
||||
// View mode state (kanban vs list)
|
||||
const { viewMode, setViewMode, isListView, sortConfig, setSortColumn } = useListViewState();
|
||||
|
||||
// Search filter for Kanban cards
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// Plan approval loading state
|
||||
@@ -324,20 +330,6 @@ export function BoardView() {
|
||||
fetchBranches();
|
||||
}, [currentProject, worktreeRefreshKey]);
|
||||
|
||||
// Calculate unarchived card counts per branch
|
||||
const branchCardCounts = useMemo(() => {
|
||||
return hookFeatures.reduce(
|
||||
(counts, feature) => {
|
||||
if (feature.status !== 'completed') {
|
||||
const branch = feature.branchName ?? 'main';
|
||||
counts[branch] = (counts[branch] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
}, [hookFeatures]);
|
||||
|
||||
// Custom collision detection that prioritizes columns over cards
|
||||
const collisionDetectionStrategy = useCallback((args: any) => {
|
||||
// First, check if pointer is within a column
|
||||
@@ -422,6 +414,22 @@ export function BoardView() {
|
||||
const selectedWorktreeBranch =
|
||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
|
||||
|
||||
// Calculate unarchived card counts per branch
|
||||
const branchCardCounts = useMemo(() => {
|
||||
// Use primary worktree branch as default for features without branchName
|
||||
const primaryBranch = worktrees.find((w) => w.isMain)?.branch || 'main';
|
||||
return hookFeatures.reduce(
|
||||
(counts, feature) => {
|
||||
if (feature.status !== 'completed') {
|
||||
const branch = feature.branchName ?? primaryBranch;
|
||||
counts[branch] = (counts[branch] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
}, [hookFeatures, worktrees]);
|
||||
|
||||
// Helper function to add and select a worktree
|
||||
const addAndSelectWorktree = useCallback(
|
||||
(worktreeResult: { path: string; branch: string }) => {
|
||||
@@ -695,6 +703,7 @@ export function BoardView() {
|
||||
model: 'opus' as const,
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: worktree.branch,
|
||||
workMode: 'custom' as const, // Use the worktree's branch
|
||||
priority: 1, // High priority for PR feedback
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
@@ -720,10 +729,11 @@ export function BoardView() {
|
||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||
);
|
||||
|
||||
// Handler for resolving conflicts - creates a feature to pull from origin/main and resolve conflicts
|
||||
// Handler for resolving conflicts - creates a feature to pull from the remote branch and resolve conflicts
|
||||
const handleResolveConflicts = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
const description = `Pull latest from origin/main and resolve conflicts. Merge origin/main into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
|
||||
const remoteBranch = `origin/${worktree.branch}`;
|
||||
const description = `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
@@ -736,6 +746,7 @@ export function BoardView() {
|
||||
model: 'opus' as const,
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: worktree.branch,
|
||||
workMode: 'custom' as const, // Use the worktree's branch
|
||||
priority: 1, // High priority for conflict resolution
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
@@ -1119,6 +1130,19 @@ export function BoardView() {
|
||||
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
|
||||
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
|
||||
currentProject,
|
||||
@@ -1304,8 +1328,8 @@ export function BoardView() {
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||
completedCount={completedFeatures.length}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
||||
@@ -1332,6 +1356,10 @@ export function BoardView() {
|
||||
}}
|
||||
onAddressPRComments={handleAddressPRComments}
|
||||
onResolveConflicts={handleResolveConflicts}
|
||||
onMerge={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowMergeWorktreeDialog(true);
|
||||
}}
|
||||
onRemovedWorktrees={handleRemovedWorktrees}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
branchCardCounts={branchCardCounts}
|
||||
@@ -1344,48 +1372,91 @@ export function BoardView() {
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* View Content - Kanban Board */}
|
||||
<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={
|
||||
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
|
||||
}
|
||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onToggleSelectionMode={toggleSelectionMode}
|
||||
isDragging={activeFeature !== null}
|
||||
onAiSuggest={() => setShowPlanDialog(true)}
|
||||
/>
|
||||
{/* View Content - Kanban Board or List View */}
|
||||
{isListView ? (
|
||||
<ListView
|
||||
columnFeaturesMap={columnFeaturesMap}
|
||||
allFeatures={hookFeatures}
|
||||
sortConfig={sortConfig}
|
||||
onSortChange={setSortColumn}
|
||||
actionHandlers={{
|
||||
onEdit: (feature) => setEditingFeature(feature),
|
||||
onDelete: (featureId) => handleDeleteFeature(featureId),
|
||||
onViewOutput: handleViewOutput,
|
||||
onVerify: handleVerifyFeature,
|
||||
onResume: handleResumeFeature,
|
||||
onForceStop: handleForceStopFeature,
|
||||
onManualVerify: handleManualVerify,
|
||||
onFollowUp: handleOpenFollowUp,
|
||||
onImplement: handleStartImplementation,
|
||||
onComplete: handleCompleteFeature,
|
||||
onViewPlan: (feature) => setViewPlanFeature(feature),
|
||||
onApprovePlan: handleOpenApprovalDialog,
|
||||
onSpawnTask: (feature) => {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
},
|
||||
}}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
pipelineConfig={pipelineConfig}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onRowClick={(feature) => {
|
||||
if (feature.status === 'backlog') {
|
||||
setEditingFeature(feature);
|
||||
} else {
|
||||
handleViewOutput(feature);
|
||||
}
|
||||
}}
|
||||
className="transition-opacity duration-200"
|
||||
/>
|
||||
) : (
|
||||
<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)}
|
||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||
completedCount={completedFeatures.length}
|
||||
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>
|
||||
|
||||
{/* Selection Action Bar */}
|
||||
@@ -1507,7 +1578,7 @@ export function BoardView() {
|
||||
open={showPipelineSettings}
|
||||
onClose={() => setShowPipelineSettings(false)}
|
||||
projectPath={currentProject.path}
|
||||
pipelineConfig={pipelineConfigByProject[currentProject.path] || null}
|
||||
pipelineConfig={pipelineConfig}
|
||||
onSave={async (config) => {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.pipeline.saveConfig(currentProject.path, config);
|
||||
@@ -1633,6 +1704,35 @@ export function BoardView() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Merge Worktree Dialog */}
|
||||
<MergeWorktreeDialog
|
||||
open={showMergeWorktreeDialog}
|
||||
onOpenChange={setShowMergeWorktreeDialog}
|
||||
projectPath={currentProject.path}
|
||||
worktree={selectedWorktreeForAction}
|
||||
affectedFeatureCount={
|
||||
selectedWorktreeForAction
|
||||
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
|
||||
: 0
|
||||
}
|
||||
onMerged={(mergedWorktree) => {
|
||||
// Reset features that were assigned to the merged worktree (by branch)
|
||||
hookFeatures.forEach((feature) => {
|
||||
if (feature.branchName === mergedWorktree.branch) {
|
||||
// Reset the feature's branch assignment - update both local state and persist
|
||||
const updates = {
|
||||
branchName: null as unknown as string | undefined,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
}
|
||||
});
|
||||
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
setSelectedWorktreeForAction(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Commit Worktree Dialog */}
|
||||
<CommitWorktreeDialog
|
||||
open={showCommitWorktreeDialog}
|
||||
@@ -1650,6 +1750,7 @@ export function BoardView() {
|
||||
onOpenChange={setShowCreatePRDialog}
|
||||
worktree={selectedWorktreeForAction}
|
||||
projectPath={currentProject?.path || null}
|
||||
defaultBaseBranch={selectedWorktreeBranch}
|
||||
onCreated={(prUrl) => {
|
||||
// If a PR was created and we have the worktree branch, update all features on that branch with the PR URL
|
||||
if (prUrl && selectedWorktreeForAction?.branch) {
|
||||
|
||||
@@ -1,65 +1,38 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ImageIcon, Archive } from 'lucide-react';
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BoardControlsProps {
|
||||
isMounted: boolean;
|
||||
onShowBoardBackground: () => void;
|
||||
onShowCompletedModal: () => void;
|
||||
completedCount: number;
|
||||
}
|
||||
|
||||
export function BoardControls({
|
||||
isMounted,
|
||||
onShowBoardBackground,
|
||||
onShowCompletedModal,
|
||||
completedCount,
|
||||
}: BoardControlsProps) {
|
||||
export function BoardControls({ isMounted, onShowBoardBackground }: BoardControlsProps) {
|
||||
if (!isMounted) return null;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Board Background Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<button
|
||||
onClick={onShowBoardBackground}
|
||||
className="h-8 px-2"
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all duration-200 cursor-pointer',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'border border-border'
|
||||
)}
|
||||
data-testid="board-background-button"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Board Background Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Completed/Archived Features Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onShowCompletedModal}
|
||||
className="h-8 px-2 relative"
|
||||
data-testid="completed-features-button"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
{completedCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Completed Features ({completedCount})</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { useCallback } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
|
||||
import { Wand2, GitBranch } from 'lucide-react';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
||||
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
|
||||
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { AutoModeSettingsPopover } from './dialogs/auto-mode-settings-popover';
|
||||
import { WorktreeSettingsPopover } from './dialogs/worktree-settings-popover';
|
||||
import { PlanSettingsPopover } from './dialogs/plan-settings-popover';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { BoardSearchBar } from './board-search-bar';
|
||||
import { BoardControls } from './board-controls';
|
||||
import { ViewToggle, type ViewMode } from './components';
|
||||
import { HeaderMobileMenu } from './header-mobile-menu';
|
||||
|
||||
export type { ViewMode };
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectPath: string;
|
||||
@@ -31,8 +33,9 @@ interface BoardHeaderProps {
|
||||
creatingSpecProjectPath?: string;
|
||||
// Board controls props
|
||||
onShowBoardBackground: () => void;
|
||||
onShowCompletedModal: () => void;
|
||||
completedCount: number;
|
||||
// View toggle props
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
}
|
||||
|
||||
// Shared styles for header control containers
|
||||
@@ -53,13 +56,9 @@ export function BoardHeader({
|
||||
isCreatingSpec,
|
||||
creatingSpecProjectPath,
|
||||
onShowBoardBackground,
|
||||
onShowCompletedModal,
|
||||
completedCount,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: BoardHeaderProps) {
|
||||
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
||||
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
|
||||
const [showPlanSettings, setShowPlanSettings] = useState(false);
|
||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
||||
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
|
||||
@@ -98,22 +97,17 @@ export function BoardHeader({
|
||||
[projectPath, setWorktreePanelVisible]
|
||||
);
|
||||
|
||||
// Claude usage tracking visibility logic
|
||||
// Hide when using API key (only show for Claude Code CLI users)
|
||||
// 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;
|
||||
const isClaudeCliVerified = !!claudeAuthStatus?.authenticated;
|
||||
const showClaudeUsage = isClaudeCliVerified;
|
||||
|
||||
// Codex usage tracking visibility logic
|
||||
// Show if Codex is authenticated (CLI or API key)
|
||||
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
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">
|
||||
<BoardSearchBar
|
||||
searchQuery={searchQuery}
|
||||
@@ -122,22 +116,39 @@ export function BoardHeader({
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
currentProjectPath={projectPath}
|
||||
/>
|
||||
<BoardControls
|
||||
isMounted={isMounted}
|
||||
onShowBoardBackground={onShowBoardBackground}
|
||||
onShowCompletedModal={onShowCompletedModal}
|
||||
completedCount={completedCount}
|
||||
/>
|
||||
{isMounted && <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} />}
|
||||
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Usage Popover - show if either provider is authenticated */}
|
||||
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
<div className="flex gap-4 items-center">
|
||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||
{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={() => {}}
|
||||
onOpenPlanDialog={onOpenPlanDialog}
|
||||
showClaudeUsage={showClaudeUsage}
|
||||
showCodexUsage={showCodexUsage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Desktop view: show full controls */}
|
||||
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
{isMounted && !isMobile && (
|
||||
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
||||
<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-xs font-medium cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
Worktree Bar
|
||||
</Label>
|
||||
<Switch
|
||||
@@ -146,72 +157,20 @@ export function BoardHeader({
|
||||
onCheckedChange={handleWorktreePanelToggle}
|
||||
data-testid="worktrees-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowWorktreeSettings(true)}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Worktree Settings"
|
||||
data-testid="worktree-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
<WorktreeSettingsPopover
|
||||
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
|
||||
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Worktree Settings Dialog */}
|
||||
<WorktreeSettingsDialog
|
||||
open={showWorktreeSettings}
|
||||
onOpenChange={setShowWorktreeSettings}
|
||||
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
|
||||
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
|
||||
/>
|
||||
|
||||
{/* Concurrency Control - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={`${controlContainerClass} cursor-pointer hover:bg-accent/50 transition-colors`}
|
||||
data-testid="concurrency-slider-container"
|
||||
>
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Agents</span>
|
||||
<span className="text-sm text-muted-foreground" data-testid="concurrency-value">
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1">Max Concurrent Agents</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls how many AI agents can run simultaneously. Higher values process more
|
||||
features in parallel but use more API resources.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
data-testid="concurrency-slider"
|
||||
/>
|
||||
<span className="text-sm font-medium min-w-[2ch] text-right">
|
||||
{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
{isMounted && !isMobile && (
|
||||
<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-xs font-medium cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
Auto Mode
|
||||
</Label>
|
||||
<Switch
|
||||
@@ -220,52 +179,33 @@ export function BoardHeader({
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
data-testid="auto-mode-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowAutoModeSettings(true)}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
<AutoModeSettingsPopover
|
||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||
maxConcurrency={maxConcurrency}
|
||||
runningAgentsCount={runningAgentsCount}
|
||||
onConcurrencyChange={onConcurrencyChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto Mode Settings Dialog */}
|
||||
<AutoModeSettingsDialog
|
||||
open={showAutoModeSettings}
|
||||
onOpenChange={setShowAutoModeSettings}
|
||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||
/>
|
||||
|
||||
{/* Plan Button with Settings */}
|
||||
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||
<button
|
||||
onClick={onOpenPlanDialog}
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPlanSettings(true)}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Plan Settings"
|
||||
data-testid="plan-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Plan Settings Dialog */}
|
||||
<PlanSettingsDialog
|
||||
open={showPlanSettings}
|
||||
onOpenChange={setShowPlanSettings}
|
||||
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
|
||||
/>
|
||||
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
|
||||
{isMounted && !isMobile && (
|
||||
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||
<button
|
||||
onClick={onOpenPlanDialog}
|
||||
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>
|
||||
</button>
|
||||
<PlanSettingsPopover
|
||||
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,3 +2,33 @@ export { KanbanCard } from './kanban-card/kanban-card';
|
||||
export { KanbanColumn } from './kanban-column';
|
||||
export { SelectionActionBar } from './selection-action-bar';
|
||||
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';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Feature, ThinkingLevel } from '@/store/app-store';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
|
||||
import type { ReasoningEffort } from '@automaker/types';
|
||||
import { getProviderFromModel } from '@/lib/utils';
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DEFAULT_MODEL,
|
||||
} from '@/lib/agent-context-parser';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import {
|
||||
Brain,
|
||||
ListTodo,
|
||||
@@ -71,6 +72,66 @@ export function AgentInfoPanel({
|
||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
||||
// Track real-time task status updates from WebSocket events
|
||||
const [taskStatusMap, setTaskStatusMap] = useState<
|
||||
Map<string, 'pending' | 'in_progress' | 'completed'>
|
||||
>(new Map());
|
||||
// Fresh planSpec data fetched from API (store data is stale for task progress)
|
||||
const [freshPlanSpec, setFreshPlanSpec] = useState<{
|
||||
tasks?: ParsedTask[];
|
||||
tasksCompleted?: number;
|
||||
currentTaskId?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
|
||||
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
|
||||
const effectiveTodos = useMemo(() => {
|
||||
// Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec
|
||||
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
|
||||
|
||||
// First priority: use planSpec.tasks if available (modern approach)
|
||||
if (planSpec?.tasks && planSpec.tasks.length > 0) {
|
||||
const completedCount = planSpec.tasksCompleted || 0;
|
||||
const currentTaskId = planSpec.currentTaskId;
|
||||
|
||||
return planSpec.tasks.map((task: ParsedTask, index: number) => {
|
||||
// Use real-time status from WebSocket events if available
|
||||
const realtimeStatus = taskStatusMap.get(task.id);
|
||||
|
||||
// Calculate status: WebSocket status > index-based status > task.status
|
||||
let effectiveStatus: 'pending' | 'in_progress' | 'completed';
|
||||
if (realtimeStatus) {
|
||||
effectiveStatus = realtimeStatus;
|
||||
} else if (index < completedCount) {
|
||||
effectiveStatus = 'completed';
|
||||
} else if (task.id === currentTaskId) {
|
||||
effectiveStatus = 'in_progress';
|
||||
} else {
|
||||
// Fallback to task.status if available, otherwise pending
|
||||
effectiveStatus =
|
||||
task.status === 'completed'
|
||||
? 'completed'
|
||||
: task.status === 'in_progress'
|
||||
? 'in_progress'
|
||||
: 'pending';
|
||||
}
|
||||
|
||||
return {
|
||||
content: task.description,
|
||||
status: effectiveStatus,
|
||||
};
|
||||
});
|
||||
}
|
||||
// Fallback: use parsed agentInfo.todos from agent-output.md
|
||||
return agentInfo?.todos || [];
|
||||
}, [
|
||||
freshPlanSpec,
|
||||
feature.planSpec?.tasks,
|
||||
feature.planSpec?.tasksCompleted,
|
||||
feature.planSpec?.currentTaskId,
|
||||
agentInfo?.todos,
|
||||
taskStatusMap,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadContext = async () => {
|
||||
@@ -82,6 +143,7 @@ export function AgentInfoPanel({
|
||||
|
||||
if (feature.status === 'backlog') {
|
||||
setAgentInfo(null);
|
||||
setFreshPlanSpec(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,6 +153,21 @@ export function AgentInfoPanel({
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
if (api.features) {
|
||||
// Fetch fresh feature data to get up-to-date planSpec (store data is stale)
|
||||
try {
|
||||
const featureResult = await api.features.get(currentProject.path, feature.id);
|
||||
const freshFeature: any = (featureResult as any).feature;
|
||||
if (featureResult.success && freshFeature?.planSpec) {
|
||||
setFreshPlanSpec({
|
||||
tasks: freshFeature.planSpec.tasks,
|
||||
tasksCompleted: freshFeature.planSpec.tasksCompleted || 0,
|
||||
currentTaskId: freshFeature.planSpec.currentTaskId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors fetching fresh planSpec
|
||||
}
|
||||
|
||||
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
|
||||
|
||||
if (result.success && result.content) {
|
||||
@@ -113,13 +190,62 @@ export function AgentInfoPanel({
|
||||
|
||||
loadContext();
|
||||
|
||||
if (isCurrentAutoTask) {
|
||||
// Poll for updates when feature is in_progress (not just isCurrentAutoTask)
|
||||
// This ensures planSpec progress stays in sync
|
||||
if (isCurrentAutoTask || feature.status === 'in_progress') {
|
||||
const interval = setInterval(loadContext, 3000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
|
||||
|
||||
// Listen to WebSocket events for real-time task status updates
|
||||
// This ensures the Kanban card shows the same progress as the Agent Output modal
|
||||
// Listen for ANY in-progress feature with planSpec tasks, not just isCurrentAutoTask
|
||||
const hasPlanSpecTasks =
|
||||
(freshPlanSpec?.tasks?.length ?? 0) > 0 || (feature.planSpec?.tasks?.length ?? 0) > 0;
|
||||
const shouldListenToEvents = feature.status === 'in_progress' && hasPlanSpecTasks;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldListenToEvents) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||
// Only handle events for this feature
|
||||
if (!('featureId' in event) || event.featureId !== feature.id) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'auto_mode_task_started':
|
||||
if ('taskId' in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_started' }>;
|
||||
setTaskStatusMap((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
// Mark current task as in_progress
|
||||
newMap.set(taskEvent.taskId, 'in_progress');
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auto_mode_task_complete':
|
||||
if ('taskId' in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_complete' }>;
|
||||
setTaskStatusMap((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(taskEvent.taskId, 'completed');
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [feature.id, shouldListenToEvents]);
|
||||
|
||||
// Model/Preset Info for Backlog Cards
|
||||
if (feature.status === 'backlog') {
|
||||
const provider = getProviderFromModel(feature.model);
|
||||
@@ -158,7 +284,9 @@ export function AgentInfoPanel({
|
||||
}
|
||||
|
||||
// Agent Info Panel for non-backlog cards
|
||||
if (feature.status !== 'backlog' && agentInfo) {
|
||||
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
|
||||
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
|
||||
if (feature.status !== 'backlog' && (agentInfo || hasPlanSpecTasks)) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 space-y-2 overflow-hidden">
|
||||
@@ -171,7 +299,7 @@ export function AgentInfoPanel({
|
||||
})()}
|
||||
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
|
||||
</div>
|
||||
{agentInfo.currentPhase && (
|
||||
{agentInfo?.currentPhase && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
|
||||
@@ -189,13 +317,13 @@ export function AgentInfoPanel({
|
||||
</div>
|
||||
|
||||
{/* Task List Progress */}
|
||||
{agentInfo.todos.length > 0 && (
|
||||
{effectiveTodos.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
<ListTodo className="w-3 h-3" />
|
||||
<span>
|
||||
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
|
||||
{agentInfo.todos.length} tasks
|
||||
{effectiveTodos.filter((t) => t.status === 'completed').length}/
|
||||
{effectiveTodos.length} tasks
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -204,7 +332,7 @@ export function AgentInfoPanel({
|
||||
isTodosExpanded ? 'max-h-40' : 'max-h-16'
|
||||
)}
|
||||
>
|
||||
{(isTodosExpanded ? agentInfo.todos : agentInfo.todos.slice(0, 3)).map(
|
||||
{(isTodosExpanded ? effectiveTodos : effectiveTodos.slice(0, 3)).map(
|
||||
(todo, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||
{todo.status === 'completed' ? (
|
||||
@@ -227,7 +355,7 @@ export function AgentInfoPanel({
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{agentInfo.todos.length > 3 && (
|
||||
{effectiveTodos.length > 3 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -237,7 +365,7 @@ export function AgentInfoPanel({
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="text-[10px] text-muted-foreground/60 pl-4 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{isTodosExpanded ? 'Show less' : `+${agentInfo.todos.length - 3} more`}
|
||||
{isTodosExpanded ? 'Show less' : `+${effectiveTodos.length - 3} more`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -247,7 +375,7 @@ export function AgentInfoPanel({
|
||||
{/* Summary for waiting_approval and verified */}
|
||||
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
|
||||
<>
|
||||
{(feature.summary || summary || agentInfo.summary) && (
|
||||
{(feature.summary || summary || agentInfo?.summary) && (
|
||||
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
|
||||
@@ -273,23 +401,23 @@ export function AgentInfoPanel({
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{feature.summary || summary || agentInfo.summary}
|
||||
{feature.summary || summary || agentInfo?.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!feature.summary &&
|
||||
!summary &&
|
||||
!agentInfo.summary &&
|
||||
agentInfo.toolCallCount > 0 && (
|
||||
!agentInfo?.summary &&
|
||||
(agentInfo?.toolCallCount ?? 0) > 0 && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
|
||||
<span className="flex items-center gap-1">
|
||||
<Wrench className="w-2.5 h-2.5" />
|
||||
{agentInfo.toolCallCount} tool calls
|
||||
{agentInfo?.toolCallCount ?? 0} tool calls
|
||||
</span>
|
||||
{agentInfo.todos.length > 0 && (
|
||||
{effectiveTodos.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
|
||||
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
|
||||
{effectiveTodos.filter((t) => t.status === 'completed').length} tasks done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -30,9 +30,7 @@ export function SelectionActionBar({
|
||||
}: SelectionActionBarProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
const allSelected = selectedCount === totalCount;
|
||||
const allSelected = selectedCount === totalCount && totalCount > 0;
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteDialog(true);
|
||||
@@ -55,7 +53,9 @@ export function SelectionActionBar({
|
||||
data-testid="selection-action-bar"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
|
||||
{selectedCount === 0
|
||||
? 'Select features to edit'
|
||||
: `${selectedCount} feature${selectedCount !== 1 ? 's' : ''} selected`}
|
||||
</span>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
@@ -65,7 +65,8 @@ export function SelectionActionBar({
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="h-8 bg-brand-500 hover:bg-brand-600"
|
||||
disabled={selectedCount === 0}
|
||||
className="h-8 bg-brand-500 hover:bg-brand-600 disabled:opacity-50"
|
||||
data-testid="selection-edit-button"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
@@ -76,7 +77,8 @@ export function SelectionActionBar({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteClick}
|
||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
disabled={selectedCount === 0}
|
||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
data-testid="selection-delete-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
|
||||
@@ -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">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">List</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -304,22 +304,22 @@ export function AgentOutputModal({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
)}
|
||||
Agent Output
|
||||
</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 && (
|
||||
<button
|
||||
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'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
@@ -332,7 +332,7 @@ export function AgentOutputModal({
|
||||
)}
|
||||
<button
|
||||
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'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
@@ -344,7 +344,7 @@ export function AgentOutputModal({
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
@@ -356,7 +356,7 @@ export function AgentOutputModal({
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
@@ -380,11 +380,11 @@ export function AgentOutputModal({
|
||||
<TaskProgressPanel
|
||||
featureId={featureId}
|
||||
projectPath={projectPath}
|
||||
className="flex-shrink-0 mx-1"
|
||||
className="flex-shrink-0 mx-3 my-2"
|
||||
/>
|
||||
|
||||
{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-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||
{projectPath ? (
|
||||
<GitDiffPanel
|
||||
projectPath={projectPath}
|
||||
@@ -401,7 +401,7 @@ export function AgentOutputModal({
|
||||
)}
|
||||
</div>
|
||||
) : 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-[200px] sm:max-h-[60vh] overflow-y-auto bg-card border border-border/50 rounded-lg p-4 scrollbar-visible">
|
||||
<Markdown>{summary}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
@@ -409,7 +409,7 @@ export function AgentOutputModal({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
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-[200px] sm:max-h-[60vh] overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs scrollbar-visible"
|
||||
>
|
||||
{isLoading && !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { FastForward, Settings2 } from 'lucide-react';
|
||||
|
||||
interface AutoModeSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
skipVerificationInAutoMode: boolean;
|
||||
onSkipVerificationChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function AutoModeSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
skipVerificationInAutoMode,
|
||||
onSkipVerificationChange,
|
||||
}: AutoModeSettingsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md" data-testid="auto-mode-settings-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings2 className="w-5 h-5" />
|
||||
Auto Mode Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure how auto mode handles feature execution and dependencies.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Skip Verification Setting */}
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="skip-verification-toggle"
|
||||
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<FastForward className="w-4 h-4 text-brand-500" />
|
||||
Skip verification requirement
|
||||
</Label>
|
||||
<Switch
|
||||
id="skip-verification-toggle"
|
||||
checked={skipVerificationInAutoMode}
|
||||
onCheckedChange={onSkipVerificationChange}
|
||||
data-testid="skip-verification-toggle"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
When enabled, auto mode will grab features even if their dependencies are not
|
||||
verified, as long as they are not currently running. This allows faster pipeline
|
||||
execution without waiting for manual verification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { FastForward, Bot, Settings2 } from 'lucide-react';
|
||||
|
||||
interface AutoModeSettingsPopoverProps {
|
||||
skipVerificationInAutoMode: boolean;
|
||||
onSkipVerificationChange: (value: boolean) => void;
|
||||
maxConcurrency: number;
|
||||
runningAgentsCount: number;
|
||||
onConcurrencyChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export function AutoModeSettingsPopover({
|
||||
skipVerificationInAutoMode,
|
||||
onSkipVerificationChange,
|
||||
maxConcurrency,
|
||||
runningAgentsCount,
|
||||
onConcurrencyChange,
|
||||
}: AutoModeSettingsPopoverProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end" sideOffset={8}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1">Auto Mode Settings</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure auto mode execution and agent concurrency.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Max Concurrent Agents */}
|
||||
<div className="space-y-2 p-2 rounded-md bg-secondary/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-brand-500 shrink-0" />
|
||||
<Label className="text-xs font-medium">Max Concurrent Agents</Label>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
data-testid="concurrency-slider"
|
||||
/>
|
||||
<span className="text-xs font-medium min-w-[2ch] text-right">{maxConcurrency}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Higher values process more features in parallel but use more API resources.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Skip Verification Setting */}
|
||||
<div className="flex items-center justify-between gap-3 p-2 rounded-md bg-secondary/50">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<FastForward className="w-4 h-4 text-brand-500 shrink-0" />
|
||||
<Label
|
||||
htmlFor="skip-verification-toggle"
|
||||
className="text-xs font-medium cursor-pointer"
|
||||
>
|
||||
Skip verification requirement
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="skip-verification-toggle"
|
||||
checked={skipVerificationInAutoMode}
|
||||
onCheckedChange={onSkipVerificationChange}
|
||||
data-testid="skip-verification-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
When enabled, auto mode will grab features even if their dependencies are not verified,
|
||||
as long as they are not currently running.
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
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 { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -37,7 +38,9 @@ export function CommitWorktreeDialog({
|
||||
}: CommitWorktreeDialogProps) {
|
||||
const [message, setMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!worktree || !message.trim()) return;
|
||||
@@ -77,11 +80,68 @@ export function CommitWorktreeDialog({
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
return (
|
||||
@@ -106,10 +166,20 @@ export function CommitWorktreeDialog({
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<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
|
||||
id="commit-message"
|
||||
placeholder="Describe your changes..."
|
||||
placeholder={
|
||||
isGenerating ? 'Generating commit message...' : 'Describe your changes...'
|
||||
}
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
setMessage(e.target.value);
|
||||
@@ -118,6 +188,7 @@ export function CommitWorktreeDialog({
|
||||
onKeyDown={handleKeyDown}
|
||||
className="min-h-[100px] font-mono text-sm"
|
||||
autoFocus
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
@@ -128,10 +199,14 @@ export function CommitWorktreeDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading || isGenerating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCommit} disabled={isLoading || !message.trim()}>
|
||||
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
@@ -30,6 +31,8 @@ interface CreatePRDialogProps {
|
||||
worktree: WorktreeInfo | null;
|
||||
projectPath: string | null;
|
||||
onCreated: (prUrl?: string) => void;
|
||||
/** Default base branch for the PR (defaults to 'main' if not provided) */
|
||||
defaultBaseBranch?: string;
|
||||
}
|
||||
|
||||
export function CreatePRDialog({
|
||||
@@ -38,10 +41,11 @@ export function CreatePRDialog({
|
||||
worktree,
|
||||
projectPath,
|
||||
onCreated,
|
||||
defaultBaseBranch = 'main',
|
||||
}: CreatePRDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [baseBranch, setBaseBranch] = useState('main');
|
||||
const [baseBranch, setBaseBranch] = useState(defaultBaseBranch);
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
const [isDraft, setIsDraft] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -49,40 +53,62 @@ export function CreatePRDialog({
|
||||
const [prUrl, setPrUrl] = useState<string | null>(null);
|
||||
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
||||
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
|
||||
// Branch fetching state
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
// Track whether an operation completed that warrants a refresh
|
||||
const operationCompletedRef = useRef(false);
|
||||
|
||||
// Common state reset function to avoid duplication
|
||||
const resetState = useCallback(() => {
|
||||
setTitle('');
|
||||
setBody('');
|
||||
setCommitMessage('');
|
||||
setBaseBranch(defaultBaseBranch);
|
||||
setIsDraft(false);
|
||||
setError(null);
|
||||
setPrUrl(null);
|
||||
setBrowserUrl(null);
|
||||
setShowBrowserFallback(false);
|
||||
operationCompletedRef.current = false;
|
||||
setBranches([]);
|
||||
}, [defaultBaseBranch]);
|
||||
|
||||
// Fetch branches for autocomplete
|
||||
const fetchBranches = useCallback(async () => {
|
||||
if (!worktree?.path) return;
|
||||
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listBranches) {
|
||||
return;
|
||||
}
|
||||
// Fetch both local and remote branches for PR base branch selection
|
||||
const result = await api.worktree.listBranches(worktree.path, true);
|
||||
if (result.success && result.result) {
|
||||
// Extract branch names, filtering out the current worktree branch
|
||||
const branchNames = result.result.branches
|
||||
.map((b) => b.name)
|
||||
.filter((name) => name !== worktree.branch);
|
||||
setBranches(branchNames);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - branches will default to main only
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
}, [worktree?.path, worktree?.branch]);
|
||||
|
||||
// Reset state when dialog opens or worktree changes
|
||||
useEffect(() => {
|
||||
// Reset all state on both open and close
|
||||
resetState();
|
||||
if (open) {
|
||||
// Reset form fields
|
||||
setTitle('');
|
||||
setBody('');
|
||||
setCommitMessage('');
|
||||
setBaseBranch('main');
|
||||
setIsDraft(false);
|
||||
setError(null);
|
||||
// Also reset result states when opening for a new worktree
|
||||
// This prevents showing stale PR URLs from previous worktrees
|
||||
setPrUrl(null);
|
||||
setBrowserUrl(null);
|
||||
setShowBrowserFallback(false);
|
||||
// Reset operation tracking
|
||||
operationCompletedRef.current = false;
|
||||
} else {
|
||||
// Reset everything when dialog closes
|
||||
setTitle('');
|
||||
setBody('');
|
||||
setCommitMessage('');
|
||||
setBaseBranch('main');
|
||||
setIsDraft(false);
|
||||
setError(null);
|
||||
setPrUrl(null);
|
||||
setBrowserUrl(null);
|
||||
setShowBrowserFallback(false);
|
||||
operationCompletedRef.current = false;
|
||||
// Fetch fresh branches when dialog opens
|
||||
fetchBranches();
|
||||
}
|
||||
}, [open, worktree?.path]);
|
||||
}, [open, worktree?.path, resetState, fetchBranches]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!worktree) return;
|
||||
@@ -343,15 +369,16 @@ export function CreatePRDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="base-branch">Base Branch</Label>
|
||||
<Input
|
||||
id="base-branch"
|
||||
placeholder="main"
|
||||
<BranchAutocomplete
|
||||
value={baseBranch}
|
||||
onChange={(e) => setBaseBranch(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
onChange={setBaseBranch}
|
||||
branches={branches}
|
||||
placeholder="Select base branch..."
|
||||
disabled={isLoadingBranches}
|
||||
data-testid="base-branch-autocomplete"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface MergeWorktreeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectPath: string;
|
||||
worktree: WorktreeInfo | null;
|
||||
onMerged: (mergedWorktree: WorktreeInfo) => void;
|
||||
/** Number of features assigned to this worktree's branch */
|
||||
affectedFeatureCount?: number;
|
||||
}
|
||||
|
||||
type DialogStep = 'confirm' | 'verify';
|
||||
|
||||
export function MergeWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectPath,
|
||||
worktree,
|
||||
onMerged,
|
||||
affectedFeatureCount = 0,
|
||||
}: MergeWorktreeDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [step, setStep] = useState<DialogStep>('confirm');
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setIsLoading(false);
|
||||
setStep('confirm');
|
||||
setConfirmText('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleProceedToVerify = () => {
|
||||
setStep('verify');
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.mergeFeature) {
|
||||
toast.error('Worktree API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass branchName and worktreePath directly to the API
|
||||
const result = await api.worktree.mergeFeature(projectPath, worktree.branch, worktree.path);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Branch merged to main', {
|
||||
description: `Branch "${worktree.branch}" has been merged and cleaned up`,
|
||||
});
|
||||
onMerged(worktree);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error('Failed to merge branch', {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to merge branch', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
const confirmationWord = 'merge';
|
||||
const isConfirmValid = confirmText.toLowerCase() === confirmationWord;
|
||||
|
||||
// First step: Show what will happen and ask for confirmation
|
||||
if (step === 'confirm') {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5 text-green-600" />
|
||||
Merge to Main
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<span className="block">
|
||||
Merge branch{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> into
|
||||
main?
|
||||
</span>
|
||||
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
This will:
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Merge the branch into the main branch</li>
|
||||
<li>Remove the worktree directory</li>
|
||||
<li>Delete the branch</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{worktree.hasChanges && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-yellow-500 text-sm">
|
||||
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
|
||||
commit or discard them before merging.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{affectedFeatureCount > 0 && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-blue-500/10 border border-blue-500/20 mt-2">
|
||||
<AlertTriangle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-blue-500 text-sm">
|
||||
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '}
|
||||
{affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch and will
|
||||
be unassigned after merge.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleProceedToVerify}
|
||||
disabled={worktree.hasChanges}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<GitMerge className="w-4 h-4 mr-2" />
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Second step: Type confirmation
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Confirm Merge
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-orange-600 dark:text-orange-400 text-sm">
|
||||
This action cannot be undone. The branch{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> will be
|
||||
permanently deleted after merging.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-merge" className="text-sm text-foreground">
|
||||
Type <span className="font-bold text-foreground">{confirmationWord}</span> to
|
||||
confirm:
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-merge"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder={confirmationWord}
|
||||
disabled={isLoading}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setStep('confirm')} disabled={isLoading}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMerge}
|
||||
disabled={isLoading || !isConfirmValid}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Merging...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Merge to Main
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { GitBranch, Settings2 } from 'lucide-react';
|
||||
|
||||
interface PlanSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
planUseSelectedWorktreeBranch: boolean;
|
||||
onPlanUseSelectedWorktreeBranchChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function PlanSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
planUseSelectedWorktreeBranch,
|
||||
onPlanUseSelectedWorktreeBranchChange,
|
||||
}: PlanSettingsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md" data-testid="plan-settings-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings2 className="w-5 h-5" />
|
||||
Plan Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure how the Plan feature creates and organizes new features.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Use Selected Worktree Branch Setting */}
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="plan-worktree-branch-toggle"
|
||||
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
Default to worktree mode
|
||||
</Label>
|
||||
<Switch
|
||||
id="plan-worktree-branch-toggle"
|
||||
checked={planUseSelectedWorktreeBranch}
|
||||
onCheckedChange={onPlanUseSelectedWorktreeBranchChange}
|
||||
data-testid="plan-worktree-branch-toggle"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Planned features will automatically use isolated worktrees, keeping changes separate
|
||||
from your main branch until you're ready to merge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { GitBranch, Settings2 } from 'lucide-react';
|
||||
|
||||
interface PlanSettingsPopoverProps {
|
||||
planUseSelectedWorktreeBranch: boolean;
|
||||
onPlanUseSelectedWorktreeBranchChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function PlanSettingsPopover({
|
||||
planUseSelectedWorktreeBranch,
|
||||
onPlanUseSelectedWorktreeBranchChange,
|
||||
}: PlanSettingsPopoverProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Plan Settings"
|
||||
data-testid="plan-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end" sideOffset={8}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1">Plan Settings</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure how Plan creates and organizes features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 p-2 rounded-md bg-secondary/50">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<GitBranch className="w-4 h-4 text-brand-500 shrink-0" />
|
||||
<Label
|
||||
htmlFor="plan-worktree-branch-toggle"
|
||||
className="text-xs font-medium cursor-pointer"
|
||||
>
|
||||
Default to worktree mode
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="plan-worktree-branch-toggle"
|
||||
checked={planUseSelectedWorktreeBranch}
|
||||
onCheckedChange={onPlanUseSelectedWorktreeBranchChange}
|
||||
data-testid="plan-worktree-branch-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
Planned features will automatically use isolated worktrees, keeping changes separate
|
||||
from your main branch until you're ready to merge.
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { GitBranch, Settings2 } from 'lucide-react';
|
||||
|
||||
interface WorktreeSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
addFeatureUseSelectedWorktreeBranch: boolean;
|
||||
onAddFeatureUseSelectedWorktreeBranchChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function WorktreeSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
addFeatureUseSelectedWorktreeBranch,
|
||||
onAddFeatureUseSelectedWorktreeBranchChange,
|
||||
}: WorktreeSettingsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md" data-testid="worktree-settings-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings2 className="w-5 h-5" />
|
||||
Worktree Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure how worktrees affect feature creation and organization.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Use Selected Worktree Branch Setting */}
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="worktree-branch-toggle"
|
||||
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
Default to worktree mode
|
||||
</Label>
|
||||
<Switch
|
||||
id="worktree-branch-toggle"
|
||||
checked={addFeatureUseSelectedWorktreeBranch}
|
||||
onCheckedChange={onAddFeatureUseSelectedWorktreeBranchChange}
|
||||
data-testid="worktree-branch-toggle"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
New features will automatically use isolated worktrees, keeping changes separate
|
||||
from your main branch until you're ready to merge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { GitBranch, Settings2 } from 'lucide-react';
|
||||
|
||||
interface WorktreeSettingsPopoverProps {
|
||||
addFeatureUseSelectedWorktreeBranch: boolean;
|
||||
onAddFeatureUseSelectedWorktreeBranchChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function WorktreeSettingsPopover({
|
||||
addFeatureUseSelectedWorktreeBranch,
|
||||
onAddFeatureUseSelectedWorktreeBranchChange,
|
||||
}: WorktreeSettingsPopoverProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Worktree Settings"
|
||||
data-testid="worktree-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end" sideOffset={8}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1">Worktree Settings</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure how worktrees affect feature creation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 p-2 rounded-md bg-secondary/50">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<GitBranch className="w-4 h-4 text-brand-500 shrink-0" />
|
||||
<Label
|
||||
htmlFor="worktree-branch-toggle"
|
||||
className="text-xs font-medium cursor-pointer"
|
||||
>
|
||||
Default to worktree mode
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="worktree-branch-toggle"
|
||||
checked={addFeatureUseSelectedWorktreeBranch}
|
||||
onCheckedChange={onAddFeatureUseSelectedWorktreeBranchChange}
|
||||
data-testid="worktree-branch-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
New features will automatically use isolated worktrees, keeping changes separate from
|
||||
your main branch until you're ready to merge.
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
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 { useFollowUpState } from './use-follow-up-state';
|
||||
export { useSelectionMode } from './use-selection-mode';
|
||||
export { useListViewState } from './use-list-view-state';
|
||||
|
||||
@@ -628,8 +628,8 @@ export function useBoardActions({
|
||||
currentProject.path,
|
||||
followUpFeature.id,
|
||||
followUpPrompt,
|
||||
imagePaths
|
||||
// No worktreePath - server derives from feature.branchName
|
||||
imagePaths,
|
||||
useWorktrees
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -667,6 +667,7 @@ export function useBoardActions({
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setFollowUpPreviewMap,
|
||||
useWorktrees,
|
||||
]);
|
||||
|
||||
const handleCommitFeature = useCallback(
|
||||
|
||||
@@ -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 { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||
import type { PipelineConfig } from '@automaker/types';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
interface KanbanBoardProps {
|
||||
sensors: any;
|
||||
collisionDetectionStrategy: (args: any) => any;
|
||||
@@ -44,6 +44,8 @@ interface KanbanBoardProps {
|
||||
runningAutoTasks: string[];
|
||||
onArchiveAllVerified: () => void;
|
||||
onAddFeature: () => void;
|
||||
onShowCompletedModal: () => void;
|
||||
completedCount: number;
|
||||
pipelineConfig: PipelineConfig | null;
|
||||
onOpenPipelineSettings?: () => void;
|
||||
// Selection mode props
|
||||
@@ -57,6 +59,8 @@ interface KanbanBoardProps {
|
||||
isDragging?: boolean;
|
||||
/** Whether the board is in read-only mode */
|
||||
isReadOnly?: boolean;
|
||||
/** Additional className for custom styling (e.g., transition classes) */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
@@ -86,6 +90,8 @@ export function KanbanBoard({
|
||||
runningAutoTasks,
|
||||
onArchiveAllVerified,
|
||||
onAddFeature,
|
||||
onShowCompletedModal,
|
||||
completedCount,
|
||||
pipelineConfig,
|
||||
onOpenPipelineSettings,
|
||||
isSelectionMode = false,
|
||||
@@ -95,6 +101,7 @@ export function KanbanBoard({
|
||||
onAiSuggest,
|
||||
isDragging = false,
|
||||
isReadOnly = false,
|
||||
className,
|
||||
}: KanbanBoardProps) {
|
||||
// Generate columns including pipeline steps
|
||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||
@@ -108,7 +115,14 @@ export function KanbanBoard({
|
||||
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
||||
|
||||
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
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
@@ -130,17 +144,36 @@ export function KanbanBoard({
|
||||
showBorder={backgroundSettings.columnBorderEnabled}
|
||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||
headerAction={
|
||||
column.id === 'verified' && columnFeatures.length > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onArchiveAllVerified}
|
||||
data-testid="archive-all-verified-button"
|
||||
>
|
||||
<Archive className="w-3 h-3 mr-1" />
|
||||
Complete All
|
||||
</Button>
|
||||
column.id === 'verified' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{columnFeatures.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onArchiveAllVerified}
|
||||
data-testid="archive-all-verified-button"
|
||||
>
|
||||
<Archive className="w-3 h-3 mr-1" />
|
||||
Complete All
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 relative"
|
||||
onClick={onShowCompletedModal}
|
||||
title={`Completed Features (${completedCount})`}
|
||||
data-testid="completed-features-button"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{completedCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : column.id === 'backlog' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
||||
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"
|
||||
onClick={() => onPrioritySelect(1)}
|
||||
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
|
||||
? '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'
|
||||
@@ -30,7 +30,7 @@ export function PrioritySelector({
|
||||
type="button"
|
||||
onClick={() => onPrioritySelect(2)}
|
||||
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
|
||||
? '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'
|
||||
@@ -43,7 +43,7 @@ export function PrioritySelector({
|
||||
type="button"
|
||||
onClick={() => onPrioritySelect(3)}
|
||||
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
|
||||
? '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'
|
||||
|
||||
@@ -20,6 +20,8 @@ interface BranchSwitchDropdownProps {
|
||||
branchFilter: string;
|
||||
isLoadingBranches: boolean;
|
||||
isSwitching: boolean;
|
||||
/** When true, renders as a standalone button (not attached to another element) */
|
||||
standalone?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onFilterChange: (value: string) => void;
|
||||
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
||||
@@ -33,6 +35,7 @@ export function BranchSwitchDropdown({
|
||||
branchFilter,
|
||||
isLoadingBranches,
|
||||
isSwitching,
|
||||
standalone = false,
|
||||
onOpenChange,
|
||||
onFilterChange,
|
||||
onSwitchBranch,
|
||||
@@ -42,16 +45,18 @@ export function BranchSwitchDropdown({
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
variant={standalone ? 'outline' : isSelected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 w-7 p-0 rounded-none border-r-0',
|
||||
isSelected && 'bg-primary text-primary-foreground',
|
||||
!isSelected && 'bg-secondary/50 hover:bg-secondary'
|
||||
'h-7 w-7 p-0',
|
||||
!standalone && 'rounded-none border-r-0',
|
||||
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"
|
||||
>
|
||||
<GitBranch className="w-3 h-3" />
|
||||
<GitBranch className={standalone ? 'w-3.5 h-3.5' : 'w-3 h-3'} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Loader2,
|
||||
Terminal,
|
||||
ArrowDown,
|
||||
ExternalLink,
|
||||
Square,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
|
||||
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
|
||||
import type { WorktreeInfo } from '../types';
|
||||
|
||||
interface DevServerLogsPanelProps {
|
||||
/** Whether the panel is open */
|
||||
open: boolean;
|
||||
/** Callback when the panel is closed */
|
||||
onClose: () => void;
|
||||
/** The worktree to show logs for */
|
||||
worktree: WorktreeInfo | null;
|
||||
/** Callback to stop the dev server */
|
||||
onStopDevServer?: (worktree: WorktreeInfo) => void;
|
||||
/** Callback to open the dev server URL in browser */
|
||||
onOpenDevServerUrl?: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel component for displaying dev server logs with ANSI color rendering
|
||||
* and auto-scroll functionality.
|
||||
*
|
||||
* Features:
|
||||
* - Real-time log streaming via WebSocket
|
||||
* - Full ANSI color code rendering via xterm.js
|
||||
* - Auto-scroll to bottom (can be paused by scrolling up)
|
||||
* - Server status indicators
|
||||
* - Quick actions (stop server, open in browser)
|
||||
*/
|
||||
export function DevServerLogsPanel({
|
||||
open,
|
||||
onClose,
|
||||
worktree,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: DevServerLogsPanelProps) {
|
||||
const xtermRef = useRef<XtermLogViewerRef>(null);
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||||
const lastLogsLengthRef = useRef(0);
|
||||
const lastWorktreePathRef = useRef<string | null>(null);
|
||||
|
||||
const {
|
||||
logs,
|
||||
isRunning,
|
||||
isLoading,
|
||||
error,
|
||||
port,
|
||||
url,
|
||||
startedAt,
|
||||
exitCode,
|
||||
serverError,
|
||||
fetchLogs,
|
||||
} = useDevServerLogs({
|
||||
worktreePath: open ? (worktree?.path ?? null) : null,
|
||||
autoSubscribe: open,
|
||||
});
|
||||
|
||||
// Write logs to xterm when they change
|
||||
useEffect(() => {
|
||||
if (!xtermRef.current || !logs) return;
|
||||
|
||||
// If worktree changed, reset the terminal and write all content
|
||||
if (lastWorktreePathRef.current !== worktree?.path) {
|
||||
lastWorktreePathRef.current = worktree?.path ?? null;
|
||||
lastLogsLengthRef.current = 0;
|
||||
xtermRef.current.write(logs);
|
||||
lastLogsLengthRef.current = logs.length;
|
||||
return;
|
||||
}
|
||||
|
||||
// If logs got shorter (e.g., cleared), rewrite all
|
||||
if (logs.length < lastLogsLengthRef.current) {
|
||||
xtermRef.current.write(logs);
|
||||
lastLogsLengthRef.current = logs.length;
|
||||
return;
|
||||
}
|
||||
|
||||
// Append only the new content
|
||||
if (logs.length > lastLogsLengthRef.current) {
|
||||
const newContent = logs.slice(lastLogsLengthRef.current);
|
||||
xtermRef.current.append(newContent);
|
||||
lastLogsLengthRef.current = logs.length;
|
||||
}
|
||||
}, [logs, worktree?.path]);
|
||||
|
||||
// Reset when panel opens with a new worktree
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setAutoScrollEnabled(true);
|
||||
if (worktree?.path !== lastWorktreePathRef.current) {
|
||||
lastLogsLengthRef.current = 0;
|
||||
}
|
||||
}
|
||||
}, [open, worktree?.path]);
|
||||
|
||||
// Scroll to bottom handler
|
||||
const scrollToBottom = useCallback(() => {
|
||||
xtermRef.current?.scrollToBottom();
|
||||
setAutoScrollEnabled(true);
|
||||
}, []);
|
||||
|
||||
// Format the started time
|
||||
const formatStartedAt = useCallback((timestamp: string | null) => {
|
||||
if (!timestamp) return null;
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
const formattedStartTime = formatStartedAt(startedAt);
|
||||
const lineCount = logs ? logs.split('\n').length : 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent
|
||||
className="w-[70vw] max-w-[900px] max-h-[85vh] flex flex-col gap-0 p-0 overflow-hidden"
|
||||
data-testid="dev-server-logs-panel"
|
||||
>
|
||||
{/* Compact Header */}
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Terminal className="w-4 h-4 text-primary" />
|
||||
<span>Dev Server</span>
|
||||
{isRunning ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-green-500/10 text-green-500 text-xs font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
Running
|
||||
</span>
|
||||
) : exitCode !== null ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-red-500/10 text-red-500 text-xs font-medium">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Stopped ({exitCode})
|
||||
</span>
|
||||
) : null}
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && url && onOpenDevServerUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
|
||||
Open
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && onStopDevServer && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onStopDevServer(worktree)}
|
||||
>
|
||||
<Square className="w-3 h-3 mr-1.5 fill-current" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => fetchLogs()}
|
||||
title="Refresh logs"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info bar - more compact */}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
<span className="font-medium text-foreground/80">{worktree.branch}</span>
|
||||
</span>
|
||||
{port && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground/60">Port</span>
|
||||
<span className="font-mono text-primary">{port}</span>
|
||||
</span>
|
||||
)}
|
||||
{formattedStartTime && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formattedStartTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Error displays - inline */}
|
||||
{(error || serverError) && (
|
||||
<div className="shrink-0 px-4 py-2 bg-destructive/5 border-b border-destructive/20">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{serverError && (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>Server error: {serverError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log content area - fills remaining space */}
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-hidden bg-zinc-950"
|
||||
data-testid="dev-server-logs-content"
|
||||
>
|
||||
{isLoading && !logs ? (
|
||||
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
<span className="text-sm">Loading logs...</span>
|
||||
</div>
|
||||
) : !logs && !isRunning ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||
<Terminal className="w-10 h-10 mb-3 opacity-20" />
|
||||
<p className="text-sm">No dev server running</p>
|
||||
<p className="text-xs mt-1 opacity-60">Start a dev server to see logs here</p>
|
||||
</div>
|
||||
) : !logs ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||
<div className="w-8 h-8 mb-3 rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground/60 animate-spin" />
|
||||
<p className="text-sm">Waiting for output...</p>
|
||||
<p className="text-xs mt-1 opacity-60">
|
||||
Logs will appear as the server generates output
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<XtermLogViewer
|
||||
ref={xtermRef}
|
||||
className="h-full"
|
||||
minHeight={280}
|
||||
fontSize={13}
|
||||
autoScroll={autoScrollEnabled}
|
||||
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
|
||||
onScrollToBottom={() => setAutoScrollEnabled(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer status bar */}
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-2 bg-muted/30 border-t border-border/50 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{lineCount > 0 ? `${lineCount} lines` : 'No output'}</span>
|
||||
{!autoScrollEnabled && logs && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-muted transition-colors text-primary"
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
Scroll to bottom
|
||||
</button>
|
||||
)}
|
||||
{autoScrollEnabled && logs && (
|
||||
<span className="inline-flex items-center gap-1.5 opacity-60">
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
Auto-scroll
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
export { DevServerLogsPanel } from './dev-server-logs-panel';
|
||||
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
|
||||
export { WorktreeTab } from './worktree-tab';
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
ScrollText,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -44,6 +45,8 @@ interface WorktreeActionsDropdownProps {
|
||||
isDevServerRunning: boolean;
|
||||
devServerInfo?: DevServerInfo;
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
/** When true, renders as a standalone button (not attached to another element) */
|
||||
standalone?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
@@ -52,10 +55,12 @@ interface WorktreeActionsDropdownProps {
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
@@ -71,6 +76,7 @@ export function WorktreeActionsDropdown({
|
||||
isDevServerRunning,
|
||||
devServerInfo,
|
||||
gitRepoStatus,
|
||||
standalone = false,
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
@@ -79,10 +85,12 @@ export function WorktreeActionsDropdown({
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onMerge,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
hasInitScript,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
@@ -115,15 +123,17 @@ export function WorktreeActionsDropdown({
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
variant={standalone ? 'outline' : isSelected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 w-7 p-0 rounded-l-none',
|
||||
isSelected && 'bg-primary text-primary-foreground',
|
||||
!isSelected && 'bg-secondary/50 hover:bg-secondary'
|
||||
'h-7 w-7 p-0',
|
||||
!standalone && 'rounded-l-none',
|
||||
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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
@@ -151,6 +161,10 @@ export function WorktreeActionsDropdown({
|
||||
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
||||
Open in Browser
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||
View Logs
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStopDevServer(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
@@ -205,21 +219,35 @@ export function WorktreeActionsDropdown({
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onResolveConflicts(worktree)}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn(
|
||||
'text-xs text-purple-500 focus:text-purple-600',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Pull & Resolve Conflicts
|
||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
{!worktree.isMain && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onResolveConflicts(worktree)}
|
||||
onClick={() => canPerformGitOps && onMerge(worktree)}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn(
|
||||
'text-xs text-purple-500 focus:text-purple-600',
|
||||
'text-xs text-green-600 focus:text-green-700',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Pull & Resolve Conflicts
|
||||
Merge to Main
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
@@ -320,7 +348,7 @@ export function WorktreeActionsDropdown({
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{/* Show PR info and Address Comments button if PR exists */}
|
||||
{!worktree.isMain && hasPR && worktree.pr && (
|
||||
{hasPR && worktree.pr && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import type { JSX } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -41,10 +41,12 @@ interface WorktreeTabProps {
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
@@ -83,10 +85,12 @@ export function WorktreeTab({
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onMerge,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
hasInitScript,
|
||||
}: WorktreeTabProps) {
|
||||
@@ -342,10 +346,12 @@ export function WorktreeTab({
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={onMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={onStartDevServer}
|
||||
onStopDevServer={onStopDevServer}
|
||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||
onViewDevServerLogs={onViewDevServerLogs}
|
||||
onRunInitScript={onRunInitScript}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { useWorktrees } from './use-worktrees';
|
||||
export { useDevServers } from './use-dev-servers';
|
||||
export { useDevServerLogs } from './use-dev-server-logs';
|
||||
export { useBranches } from './use-branches';
|
||||
export { useWorktreeActions } from './use-worktree-actions';
|
||||
export { useRunningFeatures } from './use-running-features';
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('DevServerLogs');
|
||||
|
||||
export interface DevServerLogState {
|
||||
/** The log content (buffered + live) */
|
||||
logs: string;
|
||||
/** Whether the server is currently running */
|
||||
isRunning: boolean;
|
||||
/** Whether initial logs are being fetched */
|
||||
isLoading: boolean;
|
||||
/** Error message if fetching logs failed */
|
||||
error: string | null;
|
||||
/** Server port (if running) */
|
||||
port: number | null;
|
||||
/** Server URL (if running) */
|
||||
url: string | null;
|
||||
/** Timestamp when the server started */
|
||||
startedAt: string | null;
|
||||
/** Exit code (if server stopped) */
|
||||
exitCode: number | null;
|
||||
/** Error message from server (if stopped with error) */
|
||||
serverError: string | null;
|
||||
}
|
||||
|
||||
interface UseDevServerLogsOptions {
|
||||
/** Path to the worktree to monitor logs for */
|
||||
worktreePath: string | null;
|
||||
/** Whether to automatically subscribe to log events (default: true) */
|
||||
autoSubscribe?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to subscribe to dev server log events and manage log state.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Fetches initial buffered logs from the server
|
||||
* 2. Subscribes to WebSocket events for real-time log streaming
|
||||
* 3. Handles server started/stopped events
|
||||
* 4. Provides log state for rendering in a panel
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { logs, isRunning, isLoading } = useDevServerLogs({
|
||||
* worktreePath: '/path/to/worktree'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevServerLogsOptions) {
|
||||
const [state, setState] = useState<DevServerLogState>({
|
||||
logs: '',
|
||||
isRunning: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
port: null,
|
||||
url: null,
|
||||
startedAt: null,
|
||||
exitCode: null,
|
||||
serverError: null,
|
||||
});
|
||||
|
||||
// Keep track of whether we've fetched initial logs
|
||||
const hasFetchedInitialLogs = useRef(false);
|
||||
|
||||
/**
|
||||
* Fetch buffered logs from the server
|
||||
*/
|
||||
const fetchLogs = useCallback(async () => {
|
||||
if (!worktreePath) return;
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getDevServerLogs) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Dev server logs API not available',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.getDevServerLogs(worktreePath);
|
||||
|
||||
if (result.success && result.result) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
logs: result.result!.logs,
|
||||
isRunning: true,
|
||||
isLoading: false,
|
||||
port: result.result!.port,
|
||||
url: `http://localhost:${result.result!.port}`,
|
||||
startedAt: result.result!.startedAt,
|
||||
error: null,
|
||||
}));
|
||||
hasFetchedInitialLogs.current = true;
|
||||
} else {
|
||||
// Server might not be running - this is not necessarily an error
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isRunning: false,
|
||||
error: result.error || null,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch dev server logs:', error);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch logs',
|
||||
}));
|
||||
}
|
||||
}, [worktreePath]);
|
||||
|
||||
/**
|
||||
* Clear logs and reset state
|
||||
*/
|
||||
const clearLogs = useCallback(() => {
|
||||
setState({
|
||||
logs: '',
|
||||
isRunning: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
port: null,
|
||||
url: null,
|
||||
startedAt: null,
|
||||
exitCode: null,
|
||||
serverError: null,
|
||||
});
|
||||
hasFetchedInitialLogs.current = false;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Append content to logs
|
||||
*/
|
||||
const appendLogs = useCallback((content: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
logs: prev.logs + content,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Fetch initial logs when worktreePath changes
|
||||
useEffect(() => {
|
||||
if (worktreePath && autoSubscribe) {
|
||||
hasFetchedInitialLogs.current = false;
|
||||
fetchLogs();
|
||||
} else {
|
||||
clearLogs();
|
||||
}
|
||||
}, [worktreePath, autoSubscribe, fetchLogs, clearLogs]);
|
||||
|
||||
// Subscribe to WebSocket events
|
||||
useEffect(() => {
|
||||
if (!worktreePath || !autoSubscribe) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.onDevServerLogEvent) {
|
||||
logger.warn('Dev server log event API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = api.worktree.onDevServerLogEvent((event) => {
|
||||
// Filter events to only handle those for our worktree
|
||||
if (!pathsEqual(event.payload.worktreePath, worktreePath)) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'dev-server:started': {
|
||||
const { payload } = event;
|
||||
logger.info('Dev server started:', payload);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRunning: true,
|
||||
port: payload.port,
|
||||
url: payload.url,
|
||||
startedAt: payload.timestamp,
|
||||
exitCode: null,
|
||||
serverError: null,
|
||||
// Clear logs on restart
|
||||
logs: '',
|
||||
}));
|
||||
hasFetchedInitialLogs.current = false;
|
||||
break;
|
||||
}
|
||||
case 'dev-server:output': {
|
||||
const { payload } = event;
|
||||
// Append the new output to existing logs
|
||||
if (payload.content) {
|
||||
appendLogs(payload.content);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'dev-server:stopped': {
|
||||
const { payload } = event;
|
||||
logger.info('Dev server stopped:', payload);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
exitCode: payload.exitCode,
|
||||
serverError: payload.error ?? null,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [worktreePath, autoSubscribe, appendLogs]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchLogs,
|
||||
clearLogs,
|
||||
appendLogs,
|
||||
};
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export interface WorktreePanelProps {
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||
runningFeatureIds?: string[];
|
||||
features?: FeatureInfo[];
|
||||
|
||||
@@ -4,6 +4,7 @@ import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
||||
import { cn, pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||
import {
|
||||
useWorktrees,
|
||||
@@ -12,7 +13,13 @@ import {
|
||||
useWorktreeActions,
|
||||
useRunningFeatures,
|
||||
} from './hooks';
|
||||
import { WorktreeTab } from './components';
|
||||
import {
|
||||
WorktreeTab,
|
||||
DevServerLogsPanel,
|
||||
WorktreeMobileDropdown,
|
||||
WorktreeActionsDropdown,
|
||||
BranchSwitchDropdown,
|
||||
} from './components';
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
@@ -23,6 +30,7 @@ export function WorktreePanel({
|
||||
onCreateBranch,
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onMerge,
|
||||
onRemovedWorktrees,
|
||||
runningFeatureIds = [],
|
||||
features = [],
|
||||
@@ -84,6 +92,10 @@ export function WorktreePanel({
|
||||
// Track whether init script exists for the project
|
||||
const [hasInitScript, setHasInitScript] = useState(false);
|
||||
|
||||
// Log panel state management
|
||||
const [logPanelOpen, setLogPanelOpen] = useState(false);
|
||||
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectPath) {
|
||||
setHasInitScript(false);
|
||||
@@ -103,6 +115,8 @@ export function WorktreePanel({
|
||||
checkInitScript();
|
||||
}, [projectPath]);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// 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
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -164,9 +178,122 @@ export function WorktreePanel({
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
// Handle opening the log panel for a specific worktree
|
||||
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
|
||||
setLogPanelWorktree(worktree);
|
||||
setLogPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle closing the log panel
|
||||
const handleCloseLogPanel = useCallback(() => {
|
||||
setLogPanelOpen(false);
|
||||
// Keep logPanelWorktree set for smooth close animation
|
||||
}, []);
|
||||
|
||||
const mainWorktree = worktrees.find((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}
|
||||
onMerge={onMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
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 (
|
||||
<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" />
|
||||
@@ -209,10 +336,12 @@ export function WorktreePanel({
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={onMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
@@ -265,10 +394,12 @@ export function WorktreePanel({
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={onMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
@@ -303,6 +434,15 @@ export function WorktreePanel({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
<DevServerLogsPanel
|
||||
open={logPanelOpen}
|
||||
onClose={handleCloseLogPanel}
|
||||
worktree={logPanelWorktree}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,6 +267,7 @@ export function GraphViewPage() {
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
/>
|
||||
|
||||
{/* Edit Feature Dialog */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
SelectionMode,
|
||||
ConnectionMode,
|
||||
Node,
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
} from './hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDebounceValue } from 'usehooks-ts';
|
||||
import { SearchX } from 'lucide-react';
|
||||
import { SearchX, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
||||
@@ -63,6 +64,7 @@ interface GraphCanvasProps {
|
||||
onNodeDoubleClick?: (featureId: string) => void;
|
||||
nodeActionCallbacks?: NodeActionCallbacks;
|
||||
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
|
||||
onAddFeature?: () => void;
|
||||
backgroundStyle?: React.CSSProperties;
|
||||
backgroundSettings?: BackgroundSettings;
|
||||
className?: string;
|
||||
@@ -76,6 +78,7 @@ function GraphCanvasInner({
|
||||
onNodeDoubleClick,
|
||||
nodeActionCallbacks,
|
||||
onCreateDependency,
|
||||
onAddFeature,
|
||||
backgroundStyle,
|
||||
backgroundSettings,
|
||||
className,
|
||||
@@ -244,6 +247,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
|
||||
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
|
||||
const data = node.data as TaskNodeData | undefined;
|
||||
@@ -321,6 +400,14 @@ function GraphCanvasInner({
|
||||
|
||||
<GraphLegend />
|
||||
|
||||
{/* Add Feature Button */}
|
||||
<Panel position="top-right">
|
||||
<Button variant="default" size="sm" onClick={onAddFeature} className="gap-1.5">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</Panel>
|
||||
|
||||
{/* Empty state when all nodes are filtered out */}
|
||||
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
|
||||
<Panel position="top-center" className="mt-20">
|
||||
|
||||
@@ -22,6 +22,7 @@ interface GraphViewProps {
|
||||
onUpdateFeature?: (featureId: string, updates: Partial<Feature>) => void;
|
||||
onSpawnTask?: (feature: Feature) => void;
|
||||
onDeleteTask?: (feature: Feature) => void;
|
||||
onAddFeature?: () => void;
|
||||
}
|
||||
|
||||
export function GraphView({
|
||||
@@ -40,6 +41,7 @@ export function GraphView({
|
||||
onUpdateFeature,
|
||||
onSpawnTask,
|
||||
onDeleteTask,
|
||||
onAddFeature,
|
||||
}: GraphViewProps) {
|
||||
const { currentProject } = useAppStore();
|
||||
|
||||
@@ -212,6 +214,7 @@ export function GraphView({
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
nodeActionCallbacks={nodeActionCallbacks}
|
||||
onCreateDependency={handleCreateDependency}
|
||||
onAddFeature={onAddFeature}
|
||||
backgroundStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
className="h-full"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearch } from '@tanstack/react-router';
|
||||
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 ElectronProject } from '@/lib/electron';
|
||||
|
||||
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||
const LG_BREAKPOINT = 1024;
|
||||
|
||||
export function SettingsView() {
|
||||
const {
|
||||
theme,
|
||||
@@ -41,6 +44,8 @@ export function SettingsView() {
|
||||
setEnableDependencyBlocking,
|
||||
skipVerificationInAutoMode,
|
||||
setSkipVerificationInAutoMode,
|
||||
enableAiCommitMessages,
|
||||
setEnableAiCommitMessages,
|
||||
useWorktrees,
|
||||
setUseWorktrees,
|
||||
muteDoneSound,
|
||||
@@ -108,6 +113,33 @@ export function SettingsView() {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = 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
|
||||
const renderActiveSection = () => {
|
||||
switch (activeView) {
|
||||
@@ -159,12 +191,14 @@ export function SettingsView() {
|
||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||
defaultPlanningMode={defaultPlanningMode}
|
||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||
enableAiCommitMessages={enableAiCommitMessages}
|
||||
defaultFeatureModel={defaultFeatureModel}
|
||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||
onEnableAiCommitMessagesChange={setEnableAiCommitMessages}
|
||||
onDefaultFeatureModelChange={setDefaultFeatureModel}
|
||||
/>
|
||||
);
|
||||
@@ -196,20 +230,25 @@ export function SettingsView() {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
|
||||
{/* Header Section */}
|
||||
<SettingsHeader />
|
||||
<SettingsHeader
|
||||
showNavigation={showNavigation}
|
||||
onToggleNavigation={() => setShowNavigation(!showNavigation)}
|
||||
/>
|
||||
|
||||
{/* Content Area with Sidebar */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Side Navigation - No longer scrolls, just switches views */}
|
||||
{/* Side Navigation - Overlay on mobile, sidebar on desktop */}
|
||||
<SettingsNavigation
|
||||
navItems={NAV_ITEMS}
|
||||
activeSection={activeView}
|
||||
currentProject={currentProject}
|
||||
onNavigate={handleNavigate}
|
||||
isOpen={showNavigation}
|
||||
onClose={() => setShowNavigation(false)}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,157 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
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() {
|
||||
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 (
|
||||
<div
|
||||
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="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-5 h-5 rounded-full bg-green-500/50" />
|
||||
<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-indigo-500/50" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Claude Usage Tracking
|
||||
{CLAUDE_USAGE_TITLE}
|
||||
</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>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Track your Claude Code usage limits. Uses the Claude CLI for data.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Info about CLI requirement */}
|
||||
<div className="rounded-lg bg-secondary/30 p-3 text-xs text-muted-foreground space-y-2 border border-border/50">
|
||||
<p>Usage tracking requires Claude Code CLI to be installed and authenticated:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-1">
|
||||
<li>Install Claude Code CLI if not already installed</li>
|
||||
<li>
|
||||
Run <code className="font-mono bg-muted px-1 rounded">claude login</code> to
|
||||
authenticate
|
||||
</li>
|
||||
<li>Usage data will be fetched automatically every ~minute</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{showAuthWarning && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500 mt-0.5" />
|
||||
<div className="text-sm text-amber-400">
|
||||
{CLAUDE_AUTH_WARNING} Run <span className="font-mono">{CLAUDE_LOGIN_COMMAND}</span>.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !showAuthWarning && (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Settings } from 'lucide-react';
|
||||
import { Settings, PanelLeft, PanelLeftClose } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface SettingsHeaderProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
showNavigation?: boolean;
|
||||
onToggleNavigation?: () => void;
|
||||
}
|
||||
|
||||
export function SettingsHeader({
|
||||
title = 'Settings',
|
||||
description = 'Configure your API keys and preferences',
|
||||
showNavigation,
|
||||
onToggleNavigation,
|
||||
}: SettingsHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -18,21 +23,39 @@ export function SettingsHeader({
|
||||
'bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl'
|
||||
)}
|
||||
>
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="px-4 py-4 lg:px-8 lg:py-6">
|
||||
<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
|
||||
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',
|
||||
'shadow-lg shadow-brand-500/25',
|
||||
'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>
|
||||
<h1 className="text-2xl font-bold text-foreground tracking-tight">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground/80 mt-0.5">{description}</p>
|
||||
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import type { NavigationItem, NavigationGroup } from '../config/navigation';
|
||||
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation';
|
||||
@@ -13,6 +14,8 @@ interface SettingsNavigationProps {
|
||||
activeSection: SettingsViewId;
|
||||
currentProject: Project | null;
|
||||
onNavigate: (sectionId: SettingsViewId) => void;
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function NavButton({
|
||||
@@ -167,75 +170,116 @@ export function SettingsNavigation({
|
||||
activeSection,
|
||||
currentProject,
|
||||
onNavigate,
|
||||
isOpen = true,
|
||||
onClose,
|
||||
}: 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 (
|
||||
<nav
|
||||
className={cn(
|
||||
'hidden lg:block w-64 shrink-0 overflow-y-auto',
|
||||
'border-r border-border/50',
|
||||
'bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl'
|
||||
<>
|
||||
{/* Mobile backdrop overlay - only shown when isOpen is true on mobile */}
|
||||
{isOpen && (
|
||||
<div
|
||||
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 */}
|
||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
||||
{group.label}
|
||||
{/* Navigation sidebar */}
|
||||
<nav
|
||||
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>
|
||||
))}
|
||||
|
||||
{/* Group Items */}
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) =>
|
||||
item.subItems ? (
|
||||
<NavItemWithSubItems
|
||||
key={item.id}
|
||||
item={item}
|
||||
activeSection={activeSection}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
) : (
|
||||
{/* 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>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ScrollText,
|
||||
ShieldCheck,
|
||||
FastForward,
|
||||
Sparkles,
|
||||
Cpu,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -31,12 +32,14 @@ interface FeatureDefaultsSectionProps {
|
||||
skipVerificationInAutoMode: boolean;
|
||||
defaultPlanningMode: PlanningMode;
|
||||
defaultRequirePlanApproval: boolean;
|
||||
enableAiCommitMessages: boolean;
|
||||
defaultFeatureModel: PhaseModelEntry;
|
||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||
onEnableAiCommitMessagesChange: (value: boolean) => void;
|
||||
onDefaultFeatureModelChange: (value: PhaseModelEntry) => void;
|
||||
}
|
||||
|
||||
@@ -46,12 +49,14 @@ export function FeatureDefaultsSection({
|
||||
skipVerificationInAutoMode,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
enableAiCommitMessages,
|
||||
defaultFeatureModel,
|
||||
onDefaultSkipTestsChange,
|
||||
onEnableDependencyBlockingChange,
|
||||
onSkipVerificationInAutoModeChange,
|
||||
onDefaultPlanningModeChange,
|
||||
onDefaultRequirePlanApprovalChange,
|
||||
onEnableAiCommitMessagesChange,
|
||||
onDefaultFeatureModelChange,
|
||||
}: FeatureDefaultsSectionProps) {
|
||||
return (
|
||||
@@ -281,6 +286,34 @@ export function FeatureDefaultsSection({
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,11 @@ const QUICK_TASKS: PhaseConfig[] = [
|
||||
label: 'Image Descriptions',
|
||||
description: 'Analyzes and describes context images',
|
||||
},
|
||||
{
|
||||
key: 'commitMessageModel',
|
||||
label: 'Commit Messages',
|
||||
description: 'Generates git commit messages from diffs',
|
||||
},
|
||||
];
|
||||
|
||||
const VALIDATION_TASKS: PhaseConfig[] = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import type {
|
||||
ModelAlias,
|
||||
CursorModelId,
|
||||
@@ -165,8 +166,13 @@ export function PhaseModelSelector({
|
||||
codexModelsLoading,
|
||||
fetchCodexModels,
|
||||
dynamicOpencodeModels,
|
||||
opencodeModelsLoading,
|
||||
fetchOpencodeModels,
|
||||
} = useAppStore();
|
||||
|
||||
// Detect mobile devices to use inline expansion instead of nested popovers
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Extract model and thinking/reasoning levels from value
|
||||
const selectedModel = value.model;
|
||||
const selectedThinkingLevel = value.thinkingLevel || 'none';
|
||||
@@ -181,6 +187,15 @@ export function PhaseModelSelector({
|
||||
}
|
||||
}, [codexModels.length, codexModelsLoading, fetchCodexModels]);
|
||||
|
||||
// Fetch OpenCode models on mount
|
||||
useEffect(() => {
|
||||
if (dynamicOpencodeModels.length === 0 && !opencodeModelsLoading) {
|
||||
fetchOpencodeModels().catch(() => {
|
||||
// Silently fail - user will see only static OpenCode models
|
||||
});
|
||||
}
|
||||
}, [dynamicOpencodeModels.length, opencodeModelsLoading, fetchOpencodeModels]);
|
||||
|
||||
// Close expanded group when trigger scrolls out of view
|
||||
useEffect(() => {
|
||||
const triggerElement = expandedTriggerRef.current;
|
||||
@@ -585,6 +600,107 @@ export function PhaseModelSelector({
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
@@ -829,6 +945,106 @@ export function PhaseModelSelector({
|
||||
const isExpanded = expandedClaudeModel === model.id;
|
||||
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 (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
@@ -963,6 +1179,90 @@ export function PhaseModelSelector({
|
||||
? 'Reasoning Mode'
|
||||
: '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 (
|
||||
<CommandItem
|
||||
key={group.baseId}
|
||||
@@ -1111,6 +1411,7 @@ export function PhaseModelSelector({
|
||||
className="w-[320px] p-0"
|
||||
align={align}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
onPointerDownOutside={(e) => {
|
||||
// Only prevent close if clicking inside a nested popover (thinking level panel)
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -1123,7 +1424,7 @@ export function PhaseModelSelector({
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList
|
||||
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>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RotateCcw,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
GitCommitHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PromptCustomization, CustomPrompt } from '@automaker/types';
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
DEFAULT_AGENT_PROMPTS,
|
||||
DEFAULT_BACKLOG_PLAN_PROMPTS,
|
||||
DEFAULT_ENHANCEMENT_PROMPTS,
|
||||
DEFAULT_COMMIT_MESSAGE_PROMPTS,
|
||||
} from '@automaker/prompts';
|
||||
|
||||
interface PromptCustomizationSectionProps {
|
||||
@@ -219,7 +221,7 @@ export function PromptCustomizationSection({
|
||||
{/* Tabs */}
|
||||
<div className="p-6">
|
||||
<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">
|
||||
<Bot className="w-4 h-4" />
|
||||
Auto Mode
|
||||
@@ -236,6 +238,10 @@ export function PromptCustomizationSection({
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Enhancement
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="commit-message" className="gap-2">
|
||||
<GitCommitHorizontal className="w-4 h-4" />
|
||||
Commit
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Auto Mode Tab */}
|
||||
@@ -443,6 +449,34 @@ export function PromptCustomizationSection({
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</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]);
|
||||
}
|
||||
@@ -524,7 +524,7 @@ export interface AutoModeAPI {
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
imagePaths?: string[],
|
||||
worktreePath?: string
|
||||
useWorktrees?: boolean
|
||||
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
|
||||
commitFeature: (
|
||||
projectPath: string,
|
||||
@@ -1440,13 +1440,19 @@ function createMockSetupAPI(): SetupAPI {
|
||||
// Mock Worktree API implementation
|
||||
function createMockWorktreeAPI(): WorktreeAPI {
|
||||
return {
|
||||
mergeFeature: async (projectPath: string, featureId: string, options?: object) => {
|
||||
mergeFeature: async (
|
||||
projectPath: string,
|
||||
branchName: string,
|
||||
worktreePath: string,
|
||||
options?: object
|
||||
) => {
|
||||
console.log('[Mock] Merging feature:', {
|
||||
projectPath,
|
||||
featureId,
|
||||
branchName,
|
||||
worktreePath,
|
||||
options,
|
||||
});
|
||||
return { success: true, mergedBranch: `feature/${featureId}` };
|
||||
return { success: true, mergedBranch: branchName };
|
||||
},
|
||||
|
||||
getInfo: async (projectPath: string, featureId: string) => {
|
||||
@@ -1543,6 +1549,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) => {
|
||||
console.log('[Mock] Pushing worktree:', { worktreePath, force });
|
||||
return {
|
||||
@@ -1766,6 +1780,22 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
getDevServerLogs: async (worktreePath: string) => {
|
||||
console.log('[Mock] Getting dev server logs:', { worktreePath });
|
||||
return {
|
||||
success: false,
|
||||
error: 'No dev server running for this worktree',
|
||||
};
|
||||
},
|
||||
|
||||
onDevServerLogEvent: (callback) => {
|
||||
console.log('[Mock] Subscribing to dev server log events');
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
console.log('[Mock] Unsubscribing from dev server log events');
|
||||
};
|
||||
},
|
||||
|
||||
getPRInfo: async (worktreePath: string, branchName: string) => {
|
||||
console.log('[Mock] Getting PR info:', { worktreePath, branchName });
|
||||
return {
|
||||
@@ -2089,7 +2119,7 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
imagePaths?: string[],
|
||||
worktreePath?: string
|
||||
useWorktrees?: boolean
|
||||
) => {
|
||||
if (mockRunningFeatures.has(featureId)) {
|
||||
return {
|
||||
|
||||
@@ -511,7 +511,53 @@ type EventType =
|
||||
| 'ideation:analysis'
|
||||
| 'worktree:init-started'
|
||||
| 'worktree:init-output'
|
||||
| 'worktree:init-completed';
|
||||
| 'worktree:init-completed'
|
||||
| 'dev-server:started'
|
||||
| 'dev-server:output'
|
||||
| 'dev-server:stopped';
|
||||
|
||||
/**
|
||||
* Dev server log event payloads for WebSocket streaming
|
||||
*/
|
||||
export interface DevServerStartedEvent {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface DevServerOutputEvent {
|
||||
worktreePath: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface DevServerStoppedEvent {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
exitCode: number | null;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type DevServerLogEvent =
|
||||
| { type: 'dev-server:started'; payload: DevServerStartedEvent }
|
||||
| { type: 'dev-server:output'; payload: DevServerOutputEvent }
|
||||
| { type: 'dev-server:stopped'; payload: DevServerStoppedEvent };
|
||||
|
||||
/**
|
||||
* Response type for fetching dev server logs
|
||||
*/
|
||||
export interface DevServerLogsResponse {
|
||||
success: boolean;
|
||||
result?: {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
logs: string;
|
||||
startedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type EventCallback = (payload: unknown) => void;
|
||||
|
||||
@@ -1606,14 +1652,14 @@ export class HttpApiClient implements ElectronAPI {
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
imagePaths?: string[],
|
||||
worktreePath?: string
|
||||
useWorktrees?: boolean
|
||||
) =>
|
||||
this.post('/api/auto-mode/follow-up-feature', {
|
||||
projectPath,
|
||||
featureId,
|
||||
prompt,
|
||||
imagePaths,
|
||||
worktreePath,
|
||||
useWorktrees,
|
||||
}),
|
||||
commitFeature: (projectPath: string, featureId: string, worktreePath?: string) =>
|
||||
this.post('/api/auto-mode/commit-feature', {
|
||||
@@ -1660,8 +1706,12 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
// Worktree API
|
||||
worktree: WorktreeAPI = {
|
||||
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
|
||||
this.post('/api/worktree/merge', { projectPath, featureId, options }),
|
||||
mergeFeature: (
|
||||
projectPath: string,
|
||||
branchName: string,
|
||||
worktreePath: string,
|
||||
options?: object
|
||||
) => this.post('/api/worktree/merge', { projectPath, branchName, worktreePath, options }),
|
||||
getInfo: (projectPath: string, featureId: string) =>
|
||||
this.post('/api/worktree/info', { projectPath, featureId }),
|
||||
getStatus: (projectPath: string, featureId: string) =>
|
||||
@@ -1683,6 +1733,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
}),
|
||||
commit: (worktreePath: string, message: string) =>
|
||||
this.post('/api/worktree/commit', { worktreePath, message }),
|
||||
generateCommitMessage: (worktreePath: string) =>
|
||||
this.post('/api/worktree/generate-commit-message', { worktreePath }),
|
||||
push: (worktreePath: string, force?: boolean) =>
|
||||
this.post('/api/worktree/push', { worktreePath, force }),
|
||||
createPR: (worktreePath: string, options?: any) =>
|
||||
@@ -1698,8 +1750,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }),
|
||||
checkoutBranch: (worktreePath: string, branchName: string) =>
|
||||
this.post('/api/worktree/checkout-branch', { worktreePath, branchName }),
|
||||
listBranches: (worktreePath: string) =>
|
||||
this.post('/api/worktree/list-branches', { worktreePath }),
|
||||
listBranches: (worktreePath: string, includeRemote?: boolean) =>
|
||||
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }),
|
||||
switchBranch: (worktreePath: string, branchName: string) =>
|
||||
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
|
||||
openInEditor: (worktreePath: string, editorCommand?: string) =>
|
||||
@@ -1712,6 +1764,24 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
||||
stopDevServer: (worktreePath: string) => this.post('/api/worktree/stop-dev', { worktreePath }),
|
||||
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
|
||||
getDevServerLogs: (worktreePath: string): Promise<DevServerLogsResponse> =>
|
||||
this.get(`/api/worktree/dev-server-logs?worktreePath=${encodeURIComponent(worktreePath)}`),
|
||||
onDevServerLogEvent: (callback: (event: DevServerLogEvent) => void) => {
|
||||
const unsub1 = this.subscribeToEvent('dev-server:started', (payload) =>
|
||||
callback({ type: 'dev-server:started', payload: payload as DevServerStartedEvent })
|
||||
);
|
||||
const unsub2 = this.subscribeToEvent('dev-server:output', (payload) =>
|
||||
callback({ type: 'dev-server:output', payload: payload as DevServerOutputEvent })
|
||||
);
|
||||
const unsub3 = this.subscribeToEvent('dev-server:stopped', (payload) =>
|
||||
callback({ type: 'dev-server:stopped', payload: payload as DevServerStoppedEvent })
|
||||
);
|
||||
return () => {
|
||||
unsub1();
|
||||
unsub2();
|
||||
unsub3();
|
||||
};
|
||||
},
|
||||
getPRInfo: (worktreePath: string, branchName: string) =>
|
||||
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
|
||||
// Init script methods
|
||||
|
||||
@@ -340,7 +340,7 @@ export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
|
||||
|
||||
export interface Feature extends Omit<
|
||||
BaseFeature,
|
||||
'steps' | 'imagePaths' | 'textFilePaths' | 'status'
|
||||
'steps' | 'imagePaths' | 'textFilePaths' | 'status' | 'planSpec'
|
||||
> {
|
||||
id: string;
|
||||
title?: string;
|
||||
@@ -354,6 +354,7 @@ export interface Feature extends Omit<
|
||||
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
||||
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
||||
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)
|
||||
@@ -536,6 +537,7 @@ export interface AppState {
|
||||
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)
|
||||
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
|
||||
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
|
||||
|
||||
@@ -599,6 +601,10 @@ export interface AppState {
|
||||
authenticated: boolean;
|
||||
authMethod?: string;
|
||||
}>; // Cached providers
|
||||
opencodeModelsLoading: boolean; // Whether OpenCode models are being fetched
|
||||
opencodeModelsError: string | null; // Error message if fetch failed
|
||||
opencodeModelsLastFetched: number | null; // Timestamp of last successful fetch
|
||||
opencodeModelsLastFailedAt: number | null; // Timestamp of last failed fetch
|
||||
|
||||
// Claude Agent SDK Settings
|
||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||
@@ -937,6 +943,7 @@ export interface AppActions {
|
||||
setDefaultSkipTests: (skip: boolean) => void;
|
||||
setEnableDependencyBlocking: (enabled: boolean) => void;
|
||||
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
|
||||
setEnableAiCommitMessages: (enabled: boolean) => Promise<void>;
|
||||
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||
|
||||
@@ -1179,6 +1186,9 @@ export interface AppActions {
|
||||
}>
|
||||
) => void;
|
||||
|
||||
// OpenCode Models actions
|
||||
fetchOpencodeModels: (forceRefresh?: boolean) => Promise<void>;
|
||||
|
||||
// Init Script State actions (keyed by projectPath::branch to support concurrent scripts)
|
||||
setInitScriptState: (
|
||||
projectPath: string,
|
||||
@@ -1224,6 +1234,7 @@ const initialState: AppState = {
|
||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||
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)
|
||||
addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults)
|
||||
useWorktrees: true, // Default to enabled (git worktree isolation)
|
||||
@@ -1249,6 +1260,10 @@ const initialState: AppState = {
|
||||
dynamicOpencodeModels: [], // Empty until fetched from OpenCode CLI
|
||||
enabledDynamicModelIds: [], // Empty until user enables dynamic models
|
||||
cachedOpencodeProviders: [], // Empty until fetched from OpenCode CLI
|
||||
opencodeModelsLoading: false,
|
||||
opencodeModelsError: null,
|
||||
opencodeModelsLastFetched: null,
|
||||
opencodeModelsLastFailedAt: null,
|
||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||
mcpServers: [], // No MCP servers configured by default
|
||||
@@ -1907,6 +1922,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
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) => {
|
||||
const previous = get().planUseSelectedWorktreeBranch;
|
||||
set({ planUseSelectedWorktreeBranch: enabled });
|
||||
@@ -3236,6 +3262,65 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
codexModelsLastFetched: Date.now(),
|
||||
}),
|
||||
|
||||
// OpenCode Models actions
|
||||
fetchOpencodeModels: async (forceRefresh = false) => {
|
||||
const FAILURE_COOLDOWN_MS = 30 * 1000; // 30 seconds
|
||||
const SUCCESS_CACHE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
const { opencodeModelsLastFetched, opencodeModelsLoading, opencodeModelsLastFailedAt } = get();
|
||||
|
||||
// Skip if already loading
|
||||
if (opencodeModelsLoading) return;
|
||||
|
||||
// Skip if recently failed and not forcing refresh
|
||||
if (
|
||||
!forceRefresh &&
|
||||
opencodeModelsLastFailedAt &&
|
||||
Date.now() - opencodeModelsLastFailedAt < FAILURE_COOLDOWN_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if recently fetched successfully and not forcing refresh
|
||||
if (
|
||||
!forceRefresh &&
|
||||
opencodeModelsLastFetched &&
|
||||
Date.now() - opencodeModelsLastFetched < SUCCESS_CACHE_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ opencodeModelsLoading: true, opencodeModelsError: null });
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.setup) {
|
||||
throw new Error('Setup API not available');
|
||||
}
|
||||
|
||||
const result = await api.setup.getOpencodeModels(forceRefresh);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch OpenCode models');
|
||||
}
|
||||
|
||||
set({
|
||||
dynamicOpencodeModels: result.models || [],
|
||||
opencodeModelsLastFetched: Date.now(),
|
||||
opencodeModelsLoading: false,
|
||||
opencodeModelsError: null,
|
||||
opencodeModelsLastFailedAt: null, // Clear failure on success
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
set({
|
||||
opencodeModelsError: errorMessage,
|
||||
opencodeModelsLoading: false,
|
||||
opencodeModelsLastFailedAt: Date.now(), // Record failure time for cooldown
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Pipeline actions
|
||||
setPipelineConfig: (projectPath, config) => {
|
||||
set({
|
||||
|
||||
59
apps/ui/src/types/electron.d.ts
vendored
59
apps/ui/src/types/electron.d.ts
vendored
@@ -660,14 +660,14 @@ export interface FileDiffResult {
|
||||
}
|
||||
|
||||
export interface WorktreeAPI {
|
||||
// Merge feature worktree changes back to main branch
|
||||
// Merge worktree branch into main and clean up
|
||||
mergeFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
branchName: string,
|
||||
worktreePath: string,
|
||||
options?: {
|
||||
squash?: boolean;
|
||||
commitMessage?: string;
|
||||
squashMessage?: string;
|
||||
message?: string;
|
||||
}
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
@@ -770,6 +770,13 @@ export interface WorktreeAPI {
|
||||
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: (
|
||||
worktreePath: string,
|
||||
@@ -851,8 +858,11 @@ export interface WorktreeAPI {
|
||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
||||
}>;
|
||||
|
||||
// List all local branches
|
||||
listBranches: (worktreePath: string) => Promise<{
|
||||
// List branches (local and optionally remote)
|
||||
listBranches: (
|
||||
worktreePath: string,
|
||||
includeRemote?: boolean
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
currentBranch: string;
|
||||
@@ -978,6 +988,43 @@ export interface WorktreeAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get buffered logs for a dev server
|
||||
getDevServerLogs: (worktreePath: string) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
logs: string;
|
||||
startedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Subscribe to dev server log events (started, output, stopped)
|
||||
onDevServerLogEvent: (
|
||||
callback: (
|
||||
event:
|
||||
| {
|
||||
type: 'dev-server:started';
|
||||
payload: { worktreePath: string; port: number; url: string; timestamp: string };
|
||||
}
|
||||
| {
|
||||
type: 'dev-server:output';
|
||||
payload: { worktreePath: string; content: string; timestamp: string };
|
||||
}
|
||||
| {
|
||||
type: 'dev-server:stopped';
|
||||
payload: {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
exitCode: number | null;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
};
|
||||
}
|
||||
) => void
|
||||
) => () => void;
|
||||
|
||||
// Get PR info and comments for a branch
|
||||
getPRInfo: (
|
||||
worktreePath: string,
|
||||
|
||||
@@ -21,6 +21,9 @@ services:
|
||||
# - ~/.local/share/opencode:/home/automaker/.local/share/opencode
|
||||
# - ~/.config/opencode:/home/automaker/.config/opencode
|
||||
|
||||
# Playwright browser cache - persists installed browsers across container restarts
|
||||
# Run 'npx playwright install --with-deps chromium' once, and it will persist
|
||||
# - playwright-cache:/home/automaker/.cache/ms-playwright
|
||||
environment:
|
||||
# Set root directory for all projects and file operations
|
||||
# Users can only create/open projects within this directory
|
||||
@@ -32,3 +35,8 @@ services:
|
||||
# Extract your Cursor token with: ./scripts/get-cursor-token.sh
|
||||
# Then set it here or in your .env file:
|
||||
# - CURSOR_API_KEY=${CURSOR_API_KEY:-}
|
||||
|
||||
volumes:
|
||||
# Playwright cache volume (persists Chromium installs)
|
||||
# playwright-cache:
|
||||
# name: automaker-playwright-cache
|
||||
|
||||
@@ -47,6 +47,13 @@ fi
|
||||
chown -R automaker:automaker /home/automaker/.cache/opencode
|
||||
chmod -R 700 /home/automaker/.cache/opencode
|
||||
|
||||
# Ensure npm cache directory exists with correct permissions
|
||||
# This is needed for using npx to run MCP servers
|
||||
if [ ! -d "/home/automaker/.npm" ]; then
|
||||
mkdir -p /home/automaker/.npm
|
||||
fi
|
||||
chown -R automaker:automaker /home/automaker/.npm
|
||||
|
||||
# If CURSOR_AUTH_TOKEN is set, write it to the cursor auth file
|
||||
# On Linux, cursor-agent uses ~/.config/cursor/auth.json for file-based credential storage
|
||||
# The env var CURSOR_AUTH_TOKEN is also checked directly by cursor-agent
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user