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)
|
# Ensure common system paths are in PATH (for systems without nvm)
|
||||||
# This helps find node/npm installed via Homebrew, system packages, etc.
|
# 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
|
# Run lint-staged - works with or without nvm
|
||||||
# Prefer npx, fallback to npm exec, both work with system-installed Node.js
|
# Prefer npx, fallback to npm exec, both work with system-installed Node.js
|
||||||
|
|||||||
@@ -65,8 +65,16 @@ ARG UID=1001
|
|||||||
ARG GID=1001
|
ARG GID=1001
|
||||||
|
|
||||||
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
|
# 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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git curl bash gosu ca-certificates openssh-client \
|
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" \
|
&& GH_VERSION="2.63.2" \
|
||||||
&& ARCH=$(uname -m) \
|
&& ARCH=$(uname -m) \
|
||||||
&& case "$ARCH" in \
|
&& case "$ARCH" in \
|
||||||
|
|||||||
@@ -8,9 +8,17 @@
|
|||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
|
|
||||||
# Install build dependencies for native modules (node-pty) and runtime tools
|
# 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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3 make g++ \
|
python3 make g++ \
|
||||||
git curl bash gosu ca-certificates openssh-client \
|
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" \
|
&& GH_VERSION="2.63.2" \
|
||||||
&& ARCH=$(uname -m) \
|
&& ARCH=$(uname -m) \
|
||||||
&& case "$ARCH" in \
|
&& case "$ARCH" in \
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.10.0",
|
"version": "0.11.0",
|
||||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||||
"author": "AutoMaker Team",
|
"author": "AutoMaker Team",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import { createPipelineRoutes } from './routes/pipeline/index.js';
|
|||||||
import { pipelineService } from './services/pipeline-service.js';
|
import { pipelineService } from './services/pipeline-service.js';
|
||||||
import { createIdeationRoutes } from './routes/ideation/index.js';
|
import { createIdeationRoutes } from './routes/ideation/index.js';
|
||||||
import { IdeationService } from './services/ideation-service.js';
|
import { IdeationService } from './services/ideation-service.js';
|
||||||
|
import { getDevServerService } from './services/dev-server-service.js';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -176,6 +177,10 @@ const codexUsageService = new CodexUsageService(codexAppServerService);
|
|||||||
const mcpTestService = new MCPTestService(settingsService);
|
const mcpTestService = new MCPTestService(settingsService);
|
||||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
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
|
// Initialize services
|
||||||
(async () => {
|
(async () => {
|
||||||
await agentService.initialize();
|
await agentService.initialize();
|
||||||
@@ -217,7 +222,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService));
|
|||||||
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
||||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||||
app.use('/api/worktree', createWorktreeRoutes(events));
|
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
||||||
app.use('/api/git', createGitRoutes());
|
app.use('/api/git', createGitRoutes());
|
||||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||||
app.use('/api/models', createModelsRoutes());
|
app.use('/api/models', createModelsRoutes());
|
||||||
|
|||||||
@@ -8,12 +8,28 @@ import type { Request, Response, NextFunction } from 'express';
|
|||||||
import { validatePath, PathNotAllowedError } from '@automaker/platform';
|
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')
|
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
|
||||||
* @example
|
* @example
|
||||||
* router.post('/create', validatePathParams('projectPath'), handler);
|
* router.post('/create', validatePathParams('projectPath'), handler);
|
||||||
* router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler);
|
* router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler);
|
||||||
* router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler);
|
* router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler);
|
||||||
|
* router.get('/logs', validatePathParams('worktreePath'), handler); // Works with query params too
|
||||||
*
|
*
|
||||||
* Special syntax:
|
* Special syntax:
|
||||||
* - 'paramName?' - Optional parameter (only validated if present)
|
* - 'paramName?' - Optional parameter (only validated if present)
|
||||||
@@ -26,8 +42,8 @@ export function validatePathParams(...paramNames: string[]) {
|
|||||||
// Handle optional parameters (paramName?)
|
// Handle optional parameters (paramName?)
|
||||||
if (paramName.endsWith('?')) {
|
if (paramName.endsWith('?')) {
|
||||||
const actualName = paramName.slice(0, -1);
|
const actualName = paramName.slice(0, -1);
|
||||||
const value = req.body[actualName];
|
const value = getParamValue(req, actualName);
|
||||||
if (value) {
|
if (value && typeof value === 'string') {
|
||||||
validatePath(value);
|
validatePath(value);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -36,18 +52,20 @@ export function validatePathParams(...paramNames: string[]) {
|
|||||||
// Handle array parameters (paramName[])
|
// Handle array parameters (paramName[])
|
||||||
if (paramName.endsWith('[]')) {
|
if (paramName.endsWith('[]')) {
|
||||||
const actualName = paramName.slice(0, -2);
|
const actualName = paramName.slice(0, -2);
|
||||||
const values = req.body[actualName];
|
const values = getParamValue(req, actualName);
|
||||||
if (Array.isArray(values) && values.length > 0) {
|
if (Array.isArray(values) && values.length > 0) {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
validatePath(value);
|
if (typeof value === 'string') {
|
||||||
|
validatePath(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle regular parameters
|
// Handle regular parameters
|
||||||
const value = req.body[paramName];
|
const value = getParamValue(req, paramName);
|
||||||
if (value) {
|
if (value && typeof value === 'string') {
|
||||||
validatePath(value);
|
validatePath(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import type {
|
|||||||
// Only these vars are passed - nothing else from process.env leaks through.
|
// Only these vars are passed - nothing else from process.env leaks through.
|
||||||
const ALLOWED_ENV_VARS = [
|
const ALLOWED_ENV_VARS = [
|
||||||
'ANTHROPIC_API_KEY',
|
'ANTHROPIC_API_KEY',
|
||||||
|
'ANTHROPIC_BASE_URL',
|
||||||
|
'ANTHROPIC_AUTH_TOKEN',
|
||||||
'PATH',
|
'PATH',
|
||||||
'HOME',
|
'HOME',
|
||||||
'SHELL',
|
'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 {
|
import {
|
||||||
spawnJSONLProcess,
|
|
||||||
type SubprocessOptions,
|
|
||||||
isWslAvailable,
|
|
||||||
findCliInWsl,
|
|
||||||
createWslCommand,
|
createWslCommand,
|
||||||
|
findCliInWsl,
|
||||||
|
isWslAvailable,
|
||||||
|
spawnJSONLProcess,
|
||||||
windowsToWslPath,
|
windowsToWslPath,
|
||||||
|
type SubprocessOptions,
|
||||||
type WslCliResult,
|
type WslCliResult,
|
||||||
} from '@automaker/platform';
|
} from '@automaker/platform';
|
||||||
import { createLogger, isAbortError } from '@automaker/utils';
|
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
|
* 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()}`);
|
throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cliArgs = this.buildCliArgs(options);
|
// Many CLI-based providers do not support a separate "system" message.
|
||||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
// 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 {
|
try {
|
||||||
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
|
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
|
||||||
@@ -555,4 +560,52 @@ export abstract class CliProvider extends BaseProvider {
|
|||||||
throw error;
|
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') {
|
if (this.detectedStrategy === 'npx') {
|
||||||
// NPX strategy: execute npx with opencode-ai package
|
// NPX strategy: execute npx with opencode-ai package
|
||||||
command = 'npx';
|
command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||||
args = ['opencode-ai@latest', 'models'];
|
args = ['opencode-ai@latest', 'models'];
|
||||||
opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
|
opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
|
||||||
} else if (this.useWsl && this.wslCliPath) {
|
} else if (this.useWsl && this.wslCliPath) {
|
||||||
@@ -751,6 +751,8 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
// Use shell on Windows for .cmd files
|
||||||
|
shell: process.platform === 'win32' && command.endsWith('.cmd'),
|
||||||
});
|
});
|
||||||
|
|
||||||
opencodeLogger.debug(
|
opencodeLogger.debug(
|
||||||
@@ -963,7 +965,7 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
|
|
||||||
if (this.detectedStrategy === 'npx') {
|
if (this.detectedStrategy === 'npx') {
|
||||||
// NPX strategy
|
// NPX strategy
|
||||||
command = 'npx';
|
command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||||
args = ['opencode-ai@latest', 'auth', 'list'];
|
args = ['opencode-ai@latest', 'auth', 'list'];
|
||||||
opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
|
opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
|
||||||
} else if (this.useWsl && this.wslCliPath) {
|
} else if (this.useWsl && this.wslCliPath) {
|
||||||
@@ -984,6 +986,8 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
// Use shell on Windows for .cmd files
|
||||||
|
shell: process.platform === 'win32' && command.endsWith('.cmd'),
|
||||||
});
|
});
|
||||||
|
|
||||||
opencodeLogger.debug(
|
opencodeLogger.debug(
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
|||||||
error: 'Authentication required',
|
error: 'Authentication required',
|
||||||
message: "Please run 'claude login' to authenticate",
|
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')) {
|
} else if (message.includes('timed out')) {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
error: 'Command timed out',
|
error: 'Command timed out',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { createDeleteHandler } from './routes/delete.js';
|
|||||||
import { createCreatePRHandler } from './routes/create-pr.js';
|
import { createCreatePRHandler } from './routes/create-pr.js';
|
||||||
import { createPRInfoHandler } from './routes/pr-info.js';
|
import { createPRInfoHandler } from './routes/pr-info.js';
|
||||||
import { createCommitHandler } from './routes/commit.js';
|
import { createCommitHandler } from './routes/commit.js';
|
||||||
|
import { createGenerateCommitMessageHandler } from './routes/generate-commit-message.js';
|
||||||
import { createPushHandler } from './routes/push.js';
|
import { createPushHandler } from './routes/push.js';
|
||||||
import { createPullHandler } from './routes/pull.js';
|
import { createPullHandler } from './routes/pull.js';
|
||||||
import { createCheckoutBranchHandler } from './routes/checkout-branch.js';
|
import { createCheckoutBranchHandler } from './routes/checkout-branch.js';
|
||||||
@@ -33,14 +34,19 @@ import { createMigrateHandler } from './routes/migrate.js';
|
|||||||
import { createStartDevHandler } from './routes/start-dev.js';
|
import { createStartDevHandler } from './routes/start-dev.js';
|
||||||
import { createStopDevHandler } from './routes/stop-dev.js';
|
import { createStopDevHandler } from './routes/stop-dev.js';
|
||||||
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
||||||
|
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js';
|
||||||
import {
|
import {
|
||||||
createGetInitScriptHandler,
|
createGetInitScriptHandler,
|
||||||
createPutInitScriptHandler,
|
createPutInitScriptHandler,
|
||||||
createDeleteInitScriptHandler,
|
createDeleteInitScriptHandler,
|
||||||
createRunInitScriptHandler,
|
createRunInitScriptHandler,
|
||||||
} from './routes/init-script.js';
|
} from './routes/init-script.js';
|
||||||
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
|
|
||||||
export function createWorktreeRoutes(events: EventEmitter): Router {
|
export function createWorktreeRoutes(
|
||||||
|
events: EventEmitter,
|
||||||
|
settingsService?: SettingsService
|
||||||
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
||||||
@@ -64,6 +70,12 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
|
|||||||
requireGitRepoOnly,
|
requireGitRepoOnly,
|
||||||
createCommitHandler()
|
createCommitHandler()
|
||||||
);
|
);
|
||||||
|
router.post(
|
||||||
|
'/generate-commit-message',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createGenerateCommitMessageHandler(settingsService)
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/push',
|
'/push',
|
||||||
validatePathParams('worktreePath'),
|
validatePathParams('worktreePath'),
|
||||||
@@ -97,6 +109,11 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
|
|||||||
);
|
);
|
||||||
router.post('/stop-dev', createStopDevHandler());
|
router.post('/stop-dev', createStopDevHandler());
|
||||||
router.post('/list-dev-servers', createListDevServersHandler());
|
router.post('/list-dev-servers', createListDevServersHandler());
|
||||||
|
router.get(
|
||||||
|
'/dev-server-logs',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
createGetDevServerLogsHandler()
|
||||||
|
);
|
||||||
|
|
||||||
// Init script routes
|
// Init script routes
|
||||||
router.get('/init-script', createGetInitScriptHandler());
|
router.get('/init-script', createGetInitScriptHandler());
|
||||||
|
|||||||
@@ -70,9 +70,8 @@ export function createCreatePRHandler() {
|
|||||||
logger.debug(`Changed files:\n${status}`);
|
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 commitHash: string | null = null;
|
||||||
let commitError: string | null = null;
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
const message = commitMessage || `Changes from ${branchName}`;
|
const message = commitMessage || `Changes from ${branchName}`;
|
||||||
logger.debug(`Committing changes with message: ${message}`);
|
logger.debug(`Committing changes with message: ${message}`);
|
||||||
@@ -98,14 +97,13 @@ export function createCreatePRHandler() {
|
|||||||
logger.info(`Commit successful: ${commitHash}`);
|
logger.info(`Commit successful: ${commitHash}`);
|
||||||
} catch (commitErr: unknown) {
|
} catch (commitErr: unknown) {
|
||||||
const err = commitErr as { stderr?: string; message?: string };
|
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}`);
|
logger.error(`Commit failed: ${commitError}`);
|
||||||
|
|
||||||
// Return error immediately - don't proceed with push/PR if commit fails
|
// Return error immediately - don't proceed with push/PR if commit fails
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Failed to commit changes: ${commitError}`,
|
error: `Failed to commit changes: ${commitError}`,
|
||||||
commitError,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -381,9 +379,8 @@ export function createCreatePRHandler() {
|
|||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
branch: branchName,
|
branch: branchName,
|
||||||
committed: hasChanges && !commitError,
|
committed: hasChanges,
|
||||||
commitHash,
|
commitHash,
|
||||||
commitError: commitError || undefined,
|
|
||||||
pushed: true,
|
pushed: true,
|
||||||
prUrl,
|
prUrl,
|
||||||
prNumber,
|
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
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
* the requireValidWorktree middleware in index.ts
|
* the requireValidWorktree middleware in index.ts
|
||||||
@@ -21,8 +21,9 @@ interface BranchInfo {
|
|||||||
export function createListBranchesHandler() {
|
export function createListBranchesHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath } = req.body as {
|
const { worktreePath, includeRemote = false } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
includeRemote?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
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
|
// Get ahead/behind count for current branch
|
||||||
let aheadCount = 0;
|
let aheadCount = 0;
|
||||||
let behindCount = 0;
|
let behindCount = 0;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { promisify } from 'util';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
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 { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
@@ -121,6 +121,52 @@ async function scanWorktreesDirectory(
|
|||||||
return discovered;
|
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() {
|
export function createListHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
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) {
|
for (const worktree of worktrees) {
|
||||||
const metadata = allMetadata.get(worktree.branch);
|
const metadata = allMetadata.get(worktree.branch);
|
||||||
if (metadata?.pr) {
|
if (metadata?.pr) {
|
||||||
|
// Use stored metadata (more complete info)
|
||||||
worktree.pr = metadata.pr;
|
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 type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -16,28 +15,31 @@ const execAsync = promisify(exec);
|
|||||||
export function createMergeHandler() {
|
export function createMergeHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, featureId, options } = req.body as {
|
const { projectPath, branchName, worktreePath, options } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
featureId: string;
|
branchName: string;
|
||||||
|
worktreePath: string;
|
||||||
options?: { squash?: boolean; message?: string };
|
options?: { squash?: boolean; message?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !featureId) {
|
if (!projectPath || !branchName || !worktreePath) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'projectPath and featureId required',
|
error: 'projectPath, branchName, and worktreePath are required',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const branchName = `feature/${featureId}`;
|
// Validate branch exists
|
||||||
// Git worktrees are stored in project directory
|
try {
|
||||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
|
||||||
|
} catch {
|
||||||
// Get current branch
|
res.status(400).json({
|
||||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
success: false,
|
||||||
cwd: projectPath,
|
error: `Branch "${branchName}" does not exist`,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Merge the feature branch
|
// Merge the feature branch
|
||||||
const mergeCmd = options?.squash
|
const mergeCmd = options?.squash
|
||||||
|
|||||||
@@ -49,13 +49,11 @@ export class ClaudeUsageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the claude /usage command and return the output
|
* 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> {
|
private executeClaudeUsageCommand(): Promise<string> {
|
||||||
if (this.isWindows || this.isLinux) {
|
// Use node-pty on all platforms - it's more reliable than expect on macOS
|
||||||
return this.executeClaudeUsageCommandPty();
|
return this.executeClaudeUsageCommandPty();
|
||||||
}
|
|
||||||
return this.executeClaudeUsageCommandMac();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,24 +65,36 @@ export class ClaudeUsageService {
|
|||||||
let stderr = '';
|
let stderr = '';
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
|
||||||
// Use a simple working directory (home or tmp)
|
// Use current working directory - likely already trusted by Claude CLI
|
||||||
const workingDirectory = process.env.HOME || '/tmp';
|
const workingDirectory = process.cwd();
|
||||||
|
|
||||||
// Use 'expect' with an inline script to run claude /usage with a PTY
|
// 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 = `
|
const expectScript = `
|
||||||
set timeout 20
|
set timeout 30
|
||||||
spawn claude /usage
|
spawn claude /usage
|
||||||
|
|
||||||
|
# Wait for usage data or handle trust prompt if needed
|
||||||
expect {
|
expect {
|
||||||
"Current session" {
|
-re "Ready to code|permission to work|Do you want to work" {
|
||||||
sleep 2
|
# Trust prompt appeared - send Enter to approve
|
||||||
send "\\x1b"
|
sleep 1
|
||||||
|
send "\\r"
|
||||||
|
exp_continue
|
||||||
}
|
}
|
||||||
"Esc to cancel" {
|
"Current session" {
|
||||||
|
# Usage data appeared - wait for full output, then exit
|
||||||
sleep 3
|
sleep 3
|
||||||
send "\\x1b"
|
send "\\x1b"
|
||||||
}
|
}
|
||||||
timeout {}
|
"% left" {
|
||||||
|
# Usage percentage appeared
|
||||||
|
sleep 3
|
||||||
|
send "\\x1b"
|
||||||
|
}
|
||||||
|
timeout {
|
||||||
|
send "\\x1b"
|
||||||
|
}
|
||||||
eof {}
|
eof {}
|
||||||
}
|
}
|
||||||
expect eof
|
expect eof
|
||||||
@@ -158,14 +168,18 @@ export class ClaudeUsageService {
|
|||||||
let output = '';
|
let output = '';
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let hasSeenUsageData = false;
|
let hasSeenUsageData = false;
|
||||||
|
let hasSeenTrustPrompt = false;
|
||||||
|
|
||||||
const workingDirectory = this.isWindows
|
// Use current working directory (project dir) - most likely already trusted by Claude CLI
|
||||||
? process.env.USERPROFILE || os.homedir() || 'C:\\'
|
const workingDirectory = process.cwd();
|
||||||
: process.env.HOME || os.homedir() || '/tmp';
|
|
||||||
|
|
||||||
// Use platform-appropriate shell and command
|
// Use platform-appropriate shell and command
|
||||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
// Use --add-dir to whitelist the current directory and bypass the trust prompt
|
||||||
|
// We don't pass /usage here, we'll type it into the REPL
|
||||||
|
const args = this.isWindows
|
||||||
|
? ['/c', 'claude', '--add-dir', workingDirectory]
|
||||||
|
: ['-c', `claude --add-dir "${workingDirectory}"`];
|
||||||
|
|
||||||
let ptyProcess: any = null;
|
let ptyProcess: any = null;
|
||||||
|
|
||||||
@@ -181,8 +195,6 @@ export class ClaudeUsageService {
|
|||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
});
|
});
|
||||||
} catch (spawnError) {
|
} catch (spawnError) {
|
||||||
// pty.spawn() can throw synchronously if the native module fails to load
|
|
||||||
// or if PTY is not available in the current environment (e.g., containers without /dev/pts)
|
|
||||||
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||||
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||||
|
|
||||||
@@ -204,17 +216,60 @@ export class ClaudeUsageService {
|
|||||||
// Don't fail if we have data - return it instead
|
// Don't fail if we have data - return it instead
|
||||||
if (output.includes('Current session')) {
|
if (output.includes('Current session')) {
|
||||||
resolve(output);
|
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 {
|
} else {
|
||||||
reject(new Error('Command timed out'));
|
reject(
|
||||||
|
new Error(
|
||||||
|
'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.'
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, this.timeout);
|
}, 45000); // 45 second timeout
|
||||||
|
|
||||||
|
let hasSentCommand = false;
|
||||||
|
let hasApprovedTrust = false;
|
||||||
|
|
||||||
ptyProcess.onData((data: string) => {
|
ptyProcess.onData((data: string) => {
|
||||||
output += data;
|
output += data;
|
||||||
|
|
||||||
// Check if we've seen the usage data (look for "Current session")
|
// Strip ANSI codes for easier matching
|
||||||
if (!hasSeenUsageData && output.includes('Current session')) {
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
||||||
|
|
||||||
|
// Check for specific authentication/permission errors
|
||||||
|
if (
|
||||||
|
cleanOutput.includes('OAuth token does not meet scope requirement') ||
|
||||||
|
cleanOutput.includes('permission_error') ||
|
||||||
|
cleanOutput.includes('token_expired') ||
|
||||||
|
cleanOutput.includes('authentication_error')
|
||||||
|
) {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
if (ptyProcess && !ptyProcess.killed) {
|
||||||
|
ptyProcess.kill();
|
||||||
|
}
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've seen the usage data (look for "Current session" or the TUI Usage header)
|
||||||
|
if (
|
||||||
|
!hasSeenUsageData &&
|
||||||
|
(cleanOutput.includes('Current session') ||
|
||||||
|
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')))
|
||||||
|
) {
|
||||||
hasSeenUsageData = true;
|
hasSeenUsageData = true;
|
||||||
// Wait for full output, then send escape to exit
|
// Wait for full output, then send escape to exit
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -228,16 +283,62 @@ export class ClaudeUsageService {
|
|||||||
}
|
}
|
||||||
}, 2000);
|
}, 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
|
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
||||||
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
if (
|
||||||
|
!hasSeenUsageData &&
|
||||||
|
cleanOutput.includes('Esc to cancel') &&
|
||||||
|
!cleanOutput.includes('Do you want to work in this folder?')
|
||||||
|
) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.write('\x1b'); // Send escape key
|
ptyProcess.write('\x1b'); // Send escape key
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,8 +347,11 @@ export class ClaudeUsageService {
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
|
||||||
// Check for authentication errors in output
|
if (
|
||||||
if (output.includes('token_expired') || output.includes('authentication_error')) {
|
output.includes('token_expired') ||
|
||||||
|
output.includes('authentication_error') ||
|
||||||
|
output.includes('permission_error')
|
||||||
|
) {
|
||||||
reject(new Error("Authentication required - please run 'claude login'"));
|
reject(new Error("Authentication required - please run 'claude login'"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,24 +12,123 @@ import * as secureFs from '../lib/secure-fs.js';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import type { EventEmitter } from '../lib/events.js';
|
||||||
|
|
||||||
const logger = createLogger('DevServerService');
|
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 {
|
export interface DevServerInfo {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
port: number;
|
port: number;
|
||||||
url: string;
|
url: string;
|
||||||
process: ChildProcess | null;
|
process: ChildProcess | null;
|
||||||
startedAt: Date;
|
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
|
// Port allocation starts at 3001 to avoid conflicts with common dev ports
|
||||||
const BASE_PORT = 3001;
|
const BASE_PORT = 3001;
|
||||||
const MAX_PORT = 3099; // Safety limit
|
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 {
|
class DevServerService {
|
||||||
private runningServers: Map<string, DevServerInfo> = new Map();
|
private runningServers: Map<string, DevServerInfo> = new Map();
|
||||||
private allocatedPorts: Set<number> = new Set();
|
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)
|
* 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)
|
// Reserve the port (port was already force-killed in findAvailablePort)
|
||||||
this.allocatedPorts.add(port);
|
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
|
// Some dev servers use fixed ports for HMR/livereload regardless of main port
|
||||||
const commonRelatedPorts = [35729, 35730, 35731];
|
for (const relatedPort of LIVERELOAD_PORTS) {
|
||||||
for (const relatedPort of commonRelatedPorts) {
|
|
||||||
this.killProcessOnPort(relatedPort);
|
this.killProcessOnPort(relatedPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,9 +357,14 @@ class DevServerService {
|
|||||||
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
|
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
|
||||||
|
|
||||||
// Spawn the dev process with PORT environment variable
|
// Spawn the dev process with PORT environment variable
|
||||||
|
// FORCE_COLOR enables colored output even when not running in a TTY
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
PORT: String(port),
|
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, {
|
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
|
// Track if process failed early using object to work around TypeScript narrowing
|
||||||
const status = { error: null as string | null, exited: false };
|
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) {
|
if (devProcess.stdout) {
|
||||||
devProcess.stdout.on('data', (data: Buffer) => {
|
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) {
|
if (devProcess.stderr) {
|
||||||
devProcess.stderr.on('data', (data: Buffer) => {
|
devProcess.stderr.on('data', (data: Buffer) => {
|
||||||
const msg = data.toString().trim();
|
this.handleProcessOutput(serverInfo, data);
|
||||||
logger.debug(`[Port${port}] ${msg}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) => {
|
devProcess.on('error', (error) => {
|
||||||
logger.error(`Process error:`, error);
|
logger.error(`Process error:`, error);
|
||||||
status.error = error.message;
|
status.error = error.message;
|
||||||
this.allocatedPorts.delete(port);
|
cleanupAndEmitStop(null, error.message);
|
||||||
this.runningServers.delete(worktreePath);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
devProcess.on('exit', (code) => {
|
devProcess.on('exit', (code) => {
|
||||||
logger.info(`Process for ${worktreePath} exited with code ${code}`);
|
logger.info(`Process for ${worktreePath} exited with code ${code}`);
|
||||||
status.exited = true;
|
status.exited = true;
|
||||||
this.allocatedPorts.delete(port);
|
cleanupAndEmitStop(code);
|
||||||
this.runningServers.delete(worktreePath);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait a moment to see if the process fails immediately
|
// Wait a moment to see if the process fails immediately
|
||||||
@@ -319,16 +456,19 @@ class DevServerService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverInfo: DevServerInfo = {
|
// Server started successfully - add to running servers map
|
||||||
worktreePath,
|
|
||||||
port,
|
|
||||||
url: `http://localhost:${port}`,
|
|
||||||
process: devProcess,
|
|
||||||
startedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.runningServers.set(worktreePath, serverInfo);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
@@ -365,6 +505,28 @@ class DevServerService {
|
|||||||
|
|
||||||
logger.info(`Stopping dev server for ${worktreePath}`);
|
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
|
// Kill the process
|
||||||
if (server.process && !server.process.killed) {
|
if (server.process && !server.process.killed) {
|
||||||
server.process.kill('SIGTERM');
|
server.process.kill('SIGTERM');
|
||||||
@@ -422,6 +584,41 @@ class DevServerService {
|
|||||||
return this.runningServers.get(worktreePath);
|
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
|
* Get all allocated ports
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ describe('claude-provider.ts', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
provider = new ClaudeProvider();
|
provider = new ClaudeProvider();
|
||||||
delete process.env.ANTHROPIC_API_KEY;
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
delete process.env.ANTHROPIC_BASE_URL;
|
||||||
|
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getName', () => {
|
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', () => {
|
describe('getAvailableModels', () => {
|
||||||
it('should return 4 Claude models', () => {
|
it('should return 4 Claude models', () => {
|
||||||
const models = provider.getAvailableModels();
|
const models = provider.getAvailableModels();
|
||||||
|
|||||||
@@ -551,7 +551,7 @@ Resets in 2h
|
|||||||
expect(result.sessionPercentage).toBe(35);
|
expect(result.sessionPercentage).toBe(35);
|
||||||
expect(pty.spawn).toHaveBeenCalledWith(
|
expect(pty.spawn).toHaveBeenCalledWith(
|
||||||
'cmd.exe',
|
'cmd.exe',
|
||||||
['/c', 'claude', '/usage'],
|
['/c', 'claude', '--add-dir', 'C:\\Users\\testuser'],
|
||||||
expect.any(Object)
|
expect.any(Object)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -582,8 +582,8 @@ Resets in 2h
|
|||||||
// Simulate seeing usage data
|
// Simulate seeing usage data
|
||||||
dataCallback!(mockOutput);
|
dataCallback!(mockOutput);
|
||||||
|
|
||||||
// Advance time to trigger escape key sending
|
// Advance time to trigger escape key sending (impl uses 3000ms delay)
|
||||||
vi.advanceTimersByTime(2100);
|
vi.advanceTimersByTime(3100);
|
||||||
|
|
||||||
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
|
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
|
||||||
|
|
||||||
@@ -614,9 +614,10 @@ Resets in 2h
|
|||||||
const promise = windowsService.fetchUsageData();
|
const promise = windowsService.fetchUsageData();
|
||||||
|
|
||||||
dataCallback!('authentication_error');
|
dataCallback!('authentication_error');
|
||||||
exitCallback!({ exitCode: 1 });
|
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow('Authentication required');
|
await expect(promise).rejects.toThrow(
|
||||||
|
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle timeout with no data on Windows', async () => {
|
it('should handle timeout with no data on Windows', async () => {
|
||||||
@@ -628,14 +629,18 @@ Resets in 2h
|
|||||||
onExit: vi.fn(),
|
onExit: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
kill: vi.fn(),
|
kill: vi.fn(),
|
||||||
|
killed: false,
|
||||||
};
|
};
|
||||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||||
|
|
||||||
const promise = windowsService.fetchUsageData();
|
const promise = windowsService.fetchUsageData();
|
||||||
|
|
||||||
vi.advanceTimersByTime(31000);
|
// Advance time past timeout (45 seconds)
|
||||||
|
vi.advanceTimersByTime(46000);
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow('Command timed out');
|
await expect(promise).rejects.toThrow(
|
||||||
|
'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.'
|
||||||
|
);
|
||||||
expect(mockPty.kill).toHaveBeenCalled();
|
expect(mockPty.kill).toHaveBeenCalled();
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -654,6 +659,7 @@ Resets in 2h
|
|||||||
onExit: vi.fn(),
|
onExit: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
kill: vi.fn(),
|
kill: vi.fn(),
|
||||||
|
killed: false,
|
||||||
};
|
};
|
||||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||||
|
|
||||||
@@ -662,8 +668,8 @@ Resets in 2h
|
|||||||
// Simulate receiving usage data
|
// Simulate receiving usage data
|
||||||
dataCallback!('Current session\n65% left\nResets in 2h');
|
dataCallback!('Current session\n65% left\nResets in 2h');
|
||||||
|
|
||||||
// Advance time past timeout (30 seconds)
|
// Advance time past timeout (45 seconds)
|
||||||
vi.advanceTimersByTime(31000);
|
vi.advanceTimersByTime(46000);
|
||||||
|
|
||||||
// Should resolve with data instead of rejecting
|
// Should resolve with data instead of rejecting
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
@@ -686,6 +692,7 @@ Resets in 2h
|
|||||||
onExit: vi.fn(),
|
onExit: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
kill: vi.fn(),
|
kill: vi.fn(),
|
||||||
|
killed: false,
|
||||||
};
|
};
|
||||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||||
|
|
||||||
@@ -694,8 +701,8 @@ Resets in 2h
|
|||||||
// Simulate seeing usage data
|
// Simulate seeing usage data
|
||||||
dataCallback!('Current session\n65% left');
|
dataCallback!('Current session\n65% left');
|
||||||
|
|
||||||
// Advance 2s to trigger ESC
|
// Advance 3s to trigger ESC (impl uses 3000ms delay)
|
||||||
vi.advanceTimersByTime(2100);
|
vi.advanceTimersByTime(3100);
|
||||||
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
|
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
|
||||||
|
|
||||||
// Advance another 2s to trigger SIGTERM fallback
|
// Advance another 2s to trigger SIGTERM fallback
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/ui",
|
"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",
|
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { router } from './utils/router';
|
|||||||
import { SplashScreen } from './components/splash-screen';
|
import { SplashScreen } from './components/splash-screen';
|
||||||
import { useSettingsSync } from './hooks/use-settings-sync';
|
import { useSettingsSync } from './hooks/use-settings-sync';
|
||||||
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
||||||
|
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
|
||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
import './styles/theme-imports';
|
import './styles/theme-imports';
|
||||||
|
|
||||||
@@ -24,8 +25,11 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
const clearPerfEntries = () => {
|
const clearPerfEntries = () => {
|
||||||
performance.clearMarks();
|
// Check if window.performance is available before calling its methods
|
||||||
performance.clearMeasures();
|
if (window.performance) {
|
||||||
|
window.performance.clearMarks();
|
||||||
|
window.performance.clearMeasures();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const interval = setInterval(clearPerfEntries, 5000);
|
const interval = setInterval(clearPerfEntries, 5000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
@@ -45,6 +49,9 @@ export default function App() {
|
|||||||
// Initialize Cursor CLI status at startup
|
// Initialize Cursor CLI status at startup
|
||||||
useCursorStatusInit();
|
useCursorStatusInit();
|
||||||
|
|
||||||
|
// Initialize Provider auth status at startup (for Claude/Codex usage display)
|
||||||
|
useProviderAuthInit();
|
||||||
|
|
||||||
const handleSplashComplete = useCallback(() => {
|
const handleSplashComplete = useCallback(() => {
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
setShowSplash(false);
|
setShowSplash(false);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useSetupStore } from '@/store/setup-store';
|
|||||||
const ERROR_CODES = {
|
const ERROR_CODES = {
|
||||||
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
|
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
|
||||||
AUTH_ERROR: 'AUTH_ERROR',
|
AUTH_ERROR: 'AUTH_ERROR',
|
||||||
|
TRUST_PROMPT: 'TRUST_PROMPT',
|
||||||
UNKNOWN: 'UNKNOWN',
|
UNKNOWN: 'UNKNOWN',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -55,8 +56,12 @@ export function ClaudeUsagePopover() {
|
|||||||
}
|
}
|
||||||
const data = await api.claude.getUsage();
|
const data = await api.claude.getUsage();
|
||||||
if ('error' in data) {
|
if ('error' in data) {
|
||||||
|
// Detect trust prompt error
|
||||||
|
const isTrustPrompt =
|
||||||
|
data.error === 'Trust prompt pending' ||
|
||||||
|
(data.message && data.message.includes('folder permission'));
|
||||||
setError({
|
setError({
|
||||||
code: ERROR_CODES.AUTH_ERROR,
|
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
|
||||||
message: data.message || data.error,
|
message: data.message || data.error,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -257,6 +262,11 @@ export function ClaudeUsagePopover() {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
||||||
'Ensure the Electron bridge is running or restart the app'
|
'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{' '}
|
Make sure Claude CLI is installed and authenticated via{' '}
|
||||||
|
|||||||
@@ -10,43 +10,434 @@ interface IconPickerProps {
|
|||||||
onSelectIcon: (icon: string | null) => void;
|
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 = [
|
const POPULAR_ICONS = [
|
||||||
|
// Folders & Files
|
||||||
'Folder',
|
'Folder',
|
||||||
'FolderOpen',
|
'FolderOpen',
|
||||||
'FolderCode',
|
'FolderCode',
|
||||||
'FolderGit',
|
'FolderGit',
|
||||||
'FolderKanban',
|
'FolderKanban',
|
||||||
'Package',
|
'FolderTree',
|
||||||
'Box',
|
'FolderInput',
|
||||||
'Boxes',
|
'FolderOutput',
|
||||||
|
'FolderPlus',
|
||||||
|
'File',
|
||||||
|
'FileCode',
|
||||||
|
'FileText',
|
||||||
|
'FileJson',
|
||||||
|
'FileImage',
|
||||||
|
'FileVideo',
|
||||||
|
'FileAudio',
|
||||||
|
'FileSpreadsheet',
|
||||||
|
'Files',
|
||||||
|
'Archive',
|
||||||
|
|
||||||
|
// Code & Development
|
||||||
'Code',
|
'Code',
|
||||||
'Code2',
|
'Code2',
|
||||||
'Braces',
|
'Braces',
|
||||||
'FileCode',
|
'Brackets',
|
||||||
'Terminal',
|
'Terminal',
|
||||||
'Globe',
|
'TerminalSquare',
|
||||||
'Server',
|
'Command',
|
||||||
'Database',
|
'GitBranch',
|
||||||
|
'GitCommit',
|
||||||
|
'GitMerge',
|
||||||
|
'GitPullRequest',
|
||||||
|
'GitCompare',
|
||||||
|
'GitFork',
|
||||||
|
'GitHub',
|
||||||
|
'Gitlab',
|
||||||
|
'Bitbucket',
|
||||||
|
'Vscode',
|
||||||
|
|
||||||
|
// Packages & Containers
|
||||||
|
'Package',
|
||||||
|
'PackageSearch',
|
||||||
|
'PackageCheck',
|
||||||
|
'PackageX',
|
||||||
|
'Box',
|
||||||
|
'Boxes',
|
||||||
|
'Container',
|
||||||
|
|
||||||
|
// UI & Design
|
||||||
'Layout',
|
'Layout',
|
||||||
|
'LayoutGrid',
|
||||||
|
'LayoutList',
|
||||||
|
'LayoutDashboard',
|
||||||
|
'LayoutTemplate',
|
||||||
'Layers',
|
'Layers',
|
||||||
|
'Layers2',
|
||||||
|
'Layers3',
|
||||||
'Blocks',
|
'Blocks',
|
||||||
'Component',
|
'Component',
|
||||||
'Puzzle',
|
'Palette',
|
||||||
|
'Paintbrush',
|
||||||
|
'Brush',
|
||||||
|
'PenTool',
|
||||||
|
'Ruler',
|
||||||
|
'Grid',
|
||||||
|
'Grid3x3',
|
||||||
|
'Square',
|
||||||
|
'RectangleHorizontal',
|
||||||
|
'RectangleVertical',
|
||||||
|
'Circle',
|
||||||
|
|
||||||
|
// Tools & Settings
|
||||||
'Cog',
|
'Cog',
|
||||||
|
'Settings',
|
||||||
|
'Settings2',
|
||||||
'Wrench',
|
'Wrench',
|
||||||
'Hammer',
|
'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',
|
'Zap',
|
||||||
'Rocket',
|
'Rocket',
|
||||||
'Sparkles',
|
'Flame',
|
||||||
'Star',
|
'Lightning',
|
||||||
'Heart',
|
'Bolt',
|
||||||
|
'Target',
|
||||||
|
'Flag',
|
||||||
|
'FlagTriangleRight',
|
||||||
|
'CheckCircle',
|
||||||
|
'CheckCircle2',
|
||||||
|
'XCircle',
|
||||||
|
'AlertCircle',
|
||||||
|
'Info',
|
||||||
|
'HelpCircle',
|
||||||
|
'Clock',
|
||||||
|
'Timer',
|
||||||
|
'Stopwatch',
|
||||||
|
'Calendar',
|
||||||
|
'CalendarDays',
|
||||||
|
'CalendarCheck',
|
||||||
|
'CalendarClock',
|
||||||
|
|
||||||
|
// Security & Access
|
||||||
'Shield',
|
'Shield',
|
||||||
|
'ShieldCheck',
|
||||||
|
'ShieldAlert',
|
||||||
|
'ShieldOff',
|
||||||
'Lock',
|
'Lock',
|
||||||
|
'Unlock',
|
||||||
'Key',
|
'Key',
|
||||||
'Cpu',
|
'KeyRound',
|
||||||
'CircuitBoard',
|
'Eye',
|
||||||
'Workflow',
|
'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) {
|
export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) {
|
||||||
@@ -94,7 +485,7 @@ export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Icons Grid */}
|
{/* 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">
|
<div className="grid grid-cols-6 gap-1 p-2">
|
||||||
{filteredIcons.map((iconName) => {
|
{filteredIcons.map((iconName) => {
|
||||||
const IconComponent = getIconComponent(iconName);
|
const IconComponent = getIconComponent(iconName);
|
||||||
|
|||||||
@@ -1,8 +1,105 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState, memo } from 'react';
|
||||||
import { Edit2, Trash2 } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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 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 {
|
interface ProjectContextMenuProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -18,17 +115,30 @@ export function ProjectContextMenu({
|
|||||||
onEdit,
|
onEdit,
|
||||||
}: ProjectContextMenuProps) {
|
}: ProjectContextMenuProps) {
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
setPreviewTheme(null);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
|
setPreviewTheme(null);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -40,64 +150,184 @@ export function ProjectContextMenu({
|
|||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
document.removeEventListener('keydown', handleEscape);
|
document.removeEventListener('keydown', handleEscape);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose, setPreviewTheme]);
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
onEdit(project);
|
onEdit(project);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = () => {
|
const handleRemove = () => {
|
||||||
if (confirm(`Remove "${project.name}" from the project list?`)) {
|
setShowRemoveDialog(true);
|
||||||
moveProjectToTrash(project.id);
|
};
|
||||||
|
|
||||||
|
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();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
ref={menuRef}
|
<div
|
||||||
className={cn(
|
ref={menuRef}
|
||||||
'fixed z-[100] min-w-48 rounded-lg',
|
className={cn(
|
||||||
'bg-popover text-popover-foreground',
|
'fixed min-w-48 rounded-lg',
|
||||||
'border border-border shadow-lg',
|
'bg-popover text-popover-foreground',
|
||||||
'animate-in fade-in zoom-in-95 duration-100'
|
'border border-border shadow-lg',
|
||||||
)}
|
'animate-in fade-in zoom-in-95 duration-100'
|
||||||
style={{
|
)}
|
||||||
top: position.y,
|
style={{
|
||||||
left: position.x,
|
top: position.y,
|
||||||
}}
|
left: position.x,
|
||||||
data-testid="project-context-menu"
|
zIndex: Z_INDEX.CONTEXT_MENU,
|
||||||
>
|
}}
|
||||||
<div className="p-1">
|
data-testid="project-context-menu"
|
||||||
<button
|
>
|
||||||
onClick={handleEdit}
|
<div className="p-1">
|
||||||
className={cn(
|
<button
|
||||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
onClick={handleEdit}
|
||||||
'text-sm font-medium text-left',
|
className={cn(
|
||||||
'hover:bg-accent transition-colors',
|
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||||
'focus:outline-none focus:bg-accent'
|
'text-sm font-medium text-left',
|
||||||
)}
|
'hover:bg-accent transition-colors',
|
||||||
data-testid="edit-project-button"
|
'focus:outline-none focus:bg-accent'
|
||||||
>
|
)}
|
||||||
<Edit2 className="w-4 h-4" />
|
data-testid="edit-project-button"
|
||||||
<span>Edit Name & Icon</span>
|
>
|
||||||
</button>
|
<Edit2 className="w-4 h-4" />
|
||||||
|
<span>Edit Name & Icon</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
{/* Theme Submenu Trigger */}
|
||||||
onClick={handleRemove}
|
<div
|
||||||
className={cn(
|
className="relative"
|
||||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
onMouseEnter={() => setShowThemeSubmenu(true)}
|
||||||
'text-sm font-medium text-left',
|
onMouseLeave={() => {
|
||||||
'text-destructive hover:bg-destructive/10',
|
setShowThemeSubmenu(false);
|
||||||
'transition-colors',
|
setPreviewTheme(null);
|
||||||
'focus:outline-none focus:bg-destructive/10'
|
}}
|
||||||
)}
|
>
|
||||||
data-testid="remove-project-button"
|
<button
|
||||||
>
|
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
|
||||||
<Trash2 className="w-4 h-4" />
|
className={cn(
|
||||||
<span>Remove Project</span>
|
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||||
</button>
|
'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>
|
||||||
</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 { 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 { useNavigate } from '@tanstack/react-router';
|
||||||
import { cn } from '@/lib/utils';
|
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 { useOSDetection } from '@/hooks/use-os-detection';
|
||||||
import { ProjectSwitcherItem } from './components/project-switcher-item';
|
import { ProjectSwitcherItem } from './components/project-switcher-item';
|
||||||
import { ProjectContextMenu } from './components/project-context-menu';
|
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 { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import { getElectronAPI } 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 {
|
function getOSAbbreviation(os: string): string {
|
||||||
switch (os) {
|
switch (os) {
|
||||||
@@ -34,6 +37,8 @@ export function ProjectSwitcher() {
|
|||||||
setCurrentProject,
|
setCurrentProject,
|
||||||
trashedProjects,
|
trashedProjects,
|
||||||
upsertAndSetCurrentProject,
|
upsertAndSetCurrentProject,
|
||||||
|
specCreatingForProject,
|
||||||
|
setSpecCreatingForProject,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null);
|
const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null);
|
||||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
|
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
|
||||||
@@ -41,6 +46,17 @@ export function ProjectSwitcher() {
|
|||||||
);
|
);
|
||||||
const [editDialogProject, setEditDialogProject] = useState<Project | null>(null);
|
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
|
// Version info
|
||||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
|
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
|
||||||
const { os } = useOSDetection();
|
const { os } = useOSDetection();
|
||||||
@@ -108,6 +124,109 @@ export function ProjectSwitcher() {
|
|||||||
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
|
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)
|
// Keyboard shortcuts for project switching (1-9, 0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -204,7 +323,7 @@ export function ProjectSwitcher() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Projects List */}
|
{/* 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) => (
|
{projects.map((project, index) => (
|
||||||
<ProjectSwitcherItem
|
<ProjectSwitcherItem
|
||||||
key={project.id}
|
key={project.id}
|
||||||
@@ -219,7 +338,7 @@ export function ProjectSwitcher() {
|
|||||||
{/* Horizontal rule and Add Project Button - only show if there are projects */}
|
{/* Horizontal rule and Add Project Button - only show if there are projects */}
|
||||||
{projects.length > 0 && (
|
{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
|
<button
|
||||||
onClick={handleNewProject}
|
onClick={handleNewProject}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -234,25 +353,55 @@ export function ProjectSwitcher() {
|
|||||||
>
|
>
|
||||||
<Plus className="w-5 h-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</button>
|
</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 */}
|
{/* Add Project Button - when no projects, show without rule */}
|
||||||
{projects.length === 0 && (
|
{projects.length === 0 && (
|
||||||
<button
|
<>
|
||||||
onClick={handleNewProject}
|
<button
|
||||||
className={cn(
|
onClick={handleNewProject}
|
||||||
'w-full aspect-square rounded-xl flex items-center justify-center',
|
className={cn(
|
||||||
'transition-all duration-200 ease-out',
|
'w-full aspect-square rounded-xl flex items-center justify-center',
|
||||||
'text-muted-foreground hover:text-foreground',
|
'transition-all duration-200 ease-out',
|
||||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
'text-muted-foreground hover:text-foreground',
|
||||||
'hover:shadow-sm hover:scale-105 active:scale-95'
|
'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"
|
title="New Project"
|
||||||
>
|
data-testid="new-project-button"
|
||||||
<Plus className="w-5 h-5" />
|
>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
@@ -312,6 +461,26 @@ export function ProjectSwitcher() {
|
|||||||
onSkip={handleOnboardingSkip}
|
onSkip={handleOnboardingSkip}
|
||||||
onGenerateSpec={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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile overlay backdrop */}
|
{/* Mobile backdrop overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
aria-hidden="true"
|
data-testid="sidebar-backdrop"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-shrink-0 flex flex-col z-50 relative',
|
'flex-shrink-0 flex flex-col z-30',
|
||||||
// Glass morphism background with gradient
|
// Glass morphism background with gradient
|
||||||
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
||||||
// Premium border with subtle glow
|
// Premium border with subtle glow
|
||||||
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
||||||
// Smooth width transition
|
// Smooth width transition
|
||||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||||
// Mobile: hidden when closed, full width overlay when open
|
// Mobile: overlay when open, collapsed when closed
|
||||||
// Desktop: always visible, toggle between narrow and wide
|
sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16'
|
||||||
sidebarOpen ? 'fixed lg:relative left-0 top-0 h-full w-72' : 'hidden lg:flex w-16'
|
|
||||||
)}
|
)}
|
||||||
data-testid="sidebar"
|
data-testid="sidebar"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ export function CollapseToggleButton({
|
|||||||
<button
|
<button
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
// Show on desktop always, show on mobile only when sidebar is open
|
'flex absolute top-[68px] -right-3 z-9999',
|
||||||
sidebarOpen ? 'flex' : 'hidden lg:flex',
|
|
||||||
'absolute top-[68px] -right-3 z-9999',
|
|
||||||
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
||||||
// Glass morphism button
|
// Glass morphism button
|
||||||
'bg-card/95 backdrop-blur-sm border border-border/80',
|
'bg-card/95 backdrop-blur-sm border border-border/80',
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function ProjectActions({
|
|||||||
data-testid="new-project-button"
|
data-testid="new-project-button"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:rotate-90 group-hover:text-brand-500" />
|
<Plus className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:rotate-90 group-hover:text-brand-500" />
|
||||||
<span className="ml-2 text-sm font-medium hidden lg:block whitespace-nowrap">New</span>
|
<span className="ml-2 text-sm font-medium block whitespace-nowrap">New</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenFolder}
|
onClick={handleOpenFolder}
|
||||||
@@ -59,7 +59,7 @@ export function ProjectActions({
|
|||||||
data-testid="open-project-button"
|
data-testid="open-project-button"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:scale-110" />
|
<FolderOpen className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:scale-110" />
|
||||||
<span className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted/80 text-muted-foreground ml-2">
|
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted/80 text-muted-foreground ml-2">
|
||||||
{formatShortcut(shortcuts.openProject, true)}
|
{formatShortcut(shortcuts.openProject, true)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export function SidebarFooter({
|
|||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||||
isActiveRoute('settings')
|
isActiveRoute('settings')
|
||||||
? 'bg-brand-500/20 text-brand-400'
|
? 'bg-brand-500/20 text-brand-400'
|
||||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Folder, LucideIcon } from 'lucide-react';
|
|||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import { cn, isMac } from '@/lib/utils';
|
import { cn, isMac } from '@/lib/utils';
|
||||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||||
import type { Project } from '@/lib/electron';
|
import { isElectron, type Project } from '@/lib/electron';
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
@@ -25,14 +25,17 @@ export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProp
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 flex flex-col',
|
'shrink-0 flex flex-col',
|
||||||
// Add minimal padding on macOS for traffic light buttons
|
// Add padding on macOS Electron for traffic light buttons
|
||||||
isMac && 'pt-2'
|
isMac && isElectron() && 'pt-[10px]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Project name and icon display */}
|
{/* Project name and icon display */}
|
||||||
{currentProject && (
|
{currentProject && (
|
||||||
<div
|
<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 */}
|
{/* Project Icon */}
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ export function SidebarNavigation({
|
|||||||
navigate,
|
navigate,
|
||||||
}: SidebarNavigationProps) {
|
}: SidebarNavigationProps) {
|
||||||
return (
|
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 ? (
|
{!currentProject && sidebarOpen ? (
|
||||||
// Placeholder when no project is selected (only in expanded state)
|
// Placeholder when no project is selected (only in expanded state)
|
||||||
<div className="flex items-center justify-center h-full px-4">
|
<div className="flex items-center justify-center h-full px-4">
|
||||||
<p className="text-muted-foreground text-sm text-center">
|
<p className="text-muted-foreground text-sm text-center">
|
||||||
<span className="hidden lg:block">Select or create a project above</span>
|
<span className="block">Select or create a project above</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : currentProject ? (
|
) : currentProject ? (
|
||||||
@@ -137,7 +137,7 @@ export function SidebarNavigation({
|
|||||||
{item.shortcut && sidebarOpen && !item.count && (
|
{item.shortcut && sidebarOpen && !item.count && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-brand-500/20 text-brand-400'
|
? 'bg-brand-500/20 text-brand-400'
|
||||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export function Autocomplete({
|
|||||||
width: Math.max(triggerWidth, 200),
|
width: Math.max(triggerWidth, 200),
|
||||||
}}
|
}}
|
||||||
data-testid={testId ? `${testId}-list` : undefined}
|
data-testid={testId ? `${testId}-list` : undefined}
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
onTouchMove={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
|
|||||||
@@ -78,7 +78,14 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
|
|||||||
return (
|
return (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
data-slot="command-list"
|
data-slot="command-list"
|
||||||
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
|
className={cn(
|
||||||
|
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
|
||||||
|
// Mobile touch scrolling support
|
||||||
|
'touch-pan-y overscroll-contain',
|
||||||
|
// iOS Safari momentum scrolling
|
||||||
|
'[&]:[-webkit-overflow-scrolling:touch]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export function TaskProgressPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<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 */}
|
{/* 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" />
|
<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',
|
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
|
||||||
AUTH_ERROR: 'AUTH_ERROR',
|
AUTH_ERROR: 'AUTH_ERROR',
|
||||||
NOT_AVAILABLE: 'NOT_AVAILABLE',
|
NOT_AVAILABLE: 'NOT_AVAILABLE',
|
||||||
|
TRUST_PROMPT: 'TRUST_PROMPT',
|
||||||
UNKNOWN: 'UNKNOWN',
|
UNKNOWN: 'UNKNOWN',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -72,18 +73,17 @@ export function UsagePopover() {
|
|||||||
const [codexError, setCodexError] = useState<UsageError | null>(null);
|
const [codexError, setCodexError] = useState<UsageError | null>(null);
|
||||||
|
|
||||||
// Check authentication status
|
// Check authentication status
|
||||||
const isClaudeCliVerified =
|
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
||||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
|
||||||
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
||||||
|
|
||||||
// Determine which tab to show by default
|
// Determine which tab to show by default
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isClaudeCliVerified) {
|
if (isClaudeAuthenticated) {
|
||||||
setActiveTab('claude');
|
setActiveTab('claude');
|
||||||
} else if (isCodexAuthenticated) {
|
} else if (isCodexAuthenticated) {
|
||||||
setActiveTab('codex');
|
setActiveTab('codex');
|
||||||
}
|
}
|
||||||
}, [isClaudeCliVerified, isCodexAuthenticated]);
|
}, [isClaudeAuthenticated, isCodexAuthenticated]);
|
||||||
|
|
||||||
// Check if data is stale (older than 2 minutes)
|
// Check if data is stale (older than 2 minutes)
|
||||||
const isClaudeStale = useMemo(() => {
|
const isClaudeStale = useMemo(() => {
|
||||||
@@ -109,8 +109,12 @@ export function UsagePopover() {
|
|||||||
}
|
}
|
||||||
const data = await api.claude.getUsage();
|
const data = await api.claude.getUsage();
|
||||||
if ('error' in data) {
|
if ('error' in data) {
|
||||||
|
// Detect trust prompt error
|
||||||
|
const isTrustPrompt =
|
||||||
|
data.error === 'Trust prompt pending' ||
|
||||||
|
(data.message && data.message.includes('folder permission'));
|
||||||
setClaudeError({
|
setClaudeError({
|
||||||
code: ERROR_CODES.AUTH_ERROR,
|
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
|
||||||
message: data.message || data.error,
|
message: data.message || data.error,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -174,10 +178,10 @@ export function UsagePopover() {
|
|||||||
|
|
||||||
// Auto-fetch on mount if data is stale
|
// Auto-fetch on mount if data is stale
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isClaudeStale && isClaudeCliVerified) {
|
if (isClaudeStale && isClaudeAuthenticated) {
|
||||||
fetchClaudeUsage(true);
|
fetchClaudeUsage(true);
|
||||||
}
|
}
|
||||||
}, [isClaudeStale, isClaudeCliVerified, fetchClaudeUsage]);
|
}, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCodexStale && isCodexAuthenticated) {
|
if (isCodexStale && isCodexAuthenticated) {
|
||||||
@@ -190,7 +194,7 @@ export function UsagePopover() {
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
// Fetch based on active tab
|
// Fetch based on active tab
|
||||||
if (activeTab === 'claude' && isClaudeCliVerified) {
|
if (activeTab === 'claude' && isClaudeAuthenticated) {
|
||||||
if (!claudeUsage || isClaudeStale) {
|
if (!claudeUsage || isClaudeStale) {
|
||||||
fetchClaudeUsage();
|
fetchClaudeUsage();
|
||||||
}
|
}
|
||||||
@@ -214,7 +218,7 @@ export function UsagePopover() {
|
|||||||
activeTab,
|
activeTab,
|
||||||
claudeUsage,
|
claudeUsage,
|
||||||
isClaudeStale,
|
isClaudeStale,
|
||||||
isClaudeCliVerified,
|
isClaudeAuthenticated,
|
||||||
codexUsage,
|
codexUsage,
|
||||||
isCodexStale,
|
isCodexStale,
|
||||||
isCodexAuthenticated,
|
isCodexAuthenticated,
|
||||||
@@ -349,7 +353,7 @@ export function UsagePopover() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Determine which tabs to show
|
// Determine which tabs to show
|
||||||
const showClaudeTab = isClaudeCliVerified;
|
const showClaudeTab = isClaudeAuthenticated;
|
||||||
const showCodexTab = isCodexAuthenticated;
|
const showCodexTab = isCodexAuthenticated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -405,6 +409,11 @@ export function UsagePopover() {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{claudeError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
{claudeError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
||||||
'Ensure the Electron bridge is running or restart the app'
|
'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{' '}
|
Make sure Claude CLI is installed and authenticated via{' '}
|
||||||
|
|||||||
@@ -16,11 +16,32 @@ import {
|
|||||||
import { NoProjectState, AgentHeader, ChatArea } from './agent-view/components';
|
import { NoProjectState, AgentHeader, ChatArea } from './agent-view/components';
|
||||||
import { AgentInputArea } from './agent-view/input-area';
|
import { AgentInputArea } from './agent-view/input-area';
|
||||||
|
|
||||||
|
/** Tailwind lg breakpoint in pixels */
|
||||||
|
const LG_BREAKPOINT = 1024;
|
||||||
|
|
||||||
export function AgentView() {
|
export function AgentView() {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
||||||
|
// Initialize session manager state - starts as true to match SSR
|
||||||
|
// Then updates on mount based on actual screen size to prevent hydration mismatch
|
||||||
const [showSessionManager, setShowSessionManager] = useState(true);
|
const [showSessionManager, setShowSessionManager] = useState(true);
|
||||||
|
|
||||||
|
// Update session manager visibility based on screen size after mount and on resize
|
||||||
|
useEffect(() => {
|
||||||
|
const updateVisibility = () => {
|
||||||
|
const isDesktop = window.innerWidth >= LG_BREAKPOINT;
|
||||||
|
setShowSessionManager(isDesktop);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
updateVisibility();
|
||||||
|
|
||||||
|
// Listen for resize events
|
||||||
|
window.addEventListener('resize', updateVisibility);
|
||||||
|
return () => window.removeEventListener('resize', updateVisibility);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
|
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
|
||||||
|
|
||||||
// Input ref for auto-focus
|
// Input ref for auto-focus
|
||||||
@@ -119,6 +140,13 @@ export function AgentView() {
|
|||||||
}
|
}
|
||||||
}, [currentSessionId]);
|
}, [currentSessionId]);
|
||||||
|
|
||||||
|
// Auto-close session manager on mobile when a session is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentSessionId && typeof window !== 'undefined' && window.innerWidth < 1024) {
|
||||||
|
setShowSessionManager(false);
|
||||||
|
}
|
||||||
|
}, [currentSessionId]);
|
||||||
|
|
||||||
// Show welcome message if no messages yet
|
// Show welcome message if no messages yet
|
||||||
const displayMessages =
|
const displayMessages =
|
||||||
messages.length === 0
|
messages.length === 0
|
||||||
@@ -139,9 +167,18 @@ export function AgentView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
|
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
|
||||||
|
{/* Mobile backdrop overlay for Session Manager */}
|
||||||
|
{showSessionManager && currentProject && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||||
|
onClick={() => setShowSessionManager(false)}
|
||||||
|
data-testid="session-manager-backdrop"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Session Manager Sidebar */}
|
{/* Session Manager Sidebar */}
|
||||||
{showSessionManager && currentProject && (
|
{showSessionManager && currentProject && (
|
||||||
<div className="w-80 border-r border-border shrink-0 bg-card/50">
|
<div className="fixed inset-y-0 left-0 w-72 z-30 lg:relative lg:w-80 lg:z-auto border-r border-border shrink-0 bg-card">
|
||||||
<SessionManager
|
<SessionManager
|
||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function InputControls({
|
|||||||
{/* Text Input and Controls */}
|
{/* Text Input and Controls */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex gap-2 transition-all duration-200 rounded-xl p-1',
|
'flex flex-col gap-2 transition-all duration-200 rounded-xl p-1',
|
||||||
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
|
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
|
||||||
)}
|
)}
|
||||||
onDragEnter={onDragEnter}
|
onDragEnter={onDragEnter}
|
||||||
@@ -87,7 +87,8 @@ export function InputControls({
|
|||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
>
|
>
|
||||||
<div className="flex-1 relative">
|
{/* Textarea - full width on mobile */}
|
||||||
|
<div className="relative w-full">
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={
|
placeholder={
|
||||||
@@ -105,14 +106,14 @@ export function InputControls({
|
|||||||
data-testid="agent-input"
|
data-testid="agent-input"
|
||||||
rows={1}
|
rows={1}
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
|
'min-h-11 w-full bg-background border-border rounded-xl pl-4 pr-4 sm:pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
|
||||||
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
|
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
|
||||||
hasFiles && 'border-primary/30',
|
hasFiles && 'border-primary/30',
|
||||||
isDragOver && 'border-primary bg-primary/5'
|
isDragOver && 'border-primary bg-primary/5'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{hasFiles && !isDragOver && (
|
{hasFiles && !isDragOver && (
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
|
<div className="hidden sm:block absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
|
||||||
files attached
|
files attached
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -124,58 +125,64 @@ export function InputControls({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Selector */}
|
{/* Controls row - responsive layout */}
|
||||||
<AgentModelSelector
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
value={modelSelection}
|
{/* Model Selector */}
|
||||||
onChange={onModelSelect}
|
<AgentModelSelector
|
||||||
disabled={!isConnected}
|
value={modelSelection}
|
||||||
/>
|
onChange={onModelSelect}
|
||||||
|
|
||||||
{/* File Attachment Button */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={onToggleImageDropZone}
|
|
||||||
disabled={!isConnected}
|
|
||||||
className={cn(
|
|
||||||
'h-11 w-11 rounded-xl border-border',
|
|
||||||
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
|
|
||||||
hasFiles && 'border-primary/30 text-primary'
|
|
||||||
)}
|
|
||||||
title="Attach files (images, .txt, .md)"
|
|
||||||
>
|
|
||||||
<Paperclip className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Stop Button (only when processing) */}
|
|
||||||
{isProcessing && (
|
|
||||||
<Button
|
|
||||||
onClick={onStop}
|
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
className="h-11 px-4 rounded-xl"
|
/>
|
||||||
variant="destructive"
|
|
||||||
data-testid="stop-agent"
|
|
||||||
title="Stop generation"
|
|
||||||
>
|
|
||||||
<Square className="w-4 h-4 fill-current" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Send / Queue Button */}
|
{/* File Attachment Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={onSend}
|
variant="outline"
|
||||||
disabled={!canSend}
|
size="icon"
|
||||||
className="h-11 px-4 rounded-xl"
|
onClick={onToggleImageDropZone}
|
||||||
variant={isProcessing ? 'outline' : 'default'}
|
disabled={!isConnected}
|
||||||
data-testid="send-message"
|
className={cn(
|
||||||
title={isProcessing ? 'Add to queue' : 'Send message'}
|
'h-11 w-11 rounded-xl border-border shrink-0',
|
||||||
>
|
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
|
||||||
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
|
hasFiles && 'border-primary/30 text-primary'
|
||||||
</Button>
|
)}
|
||||||
|
title="Attach files (images, .txt, .md)"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Spacer to push action buttons to the right */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Stop Button (only when processing) */}
|
||||||
|
{isProcessing && (
|
||||||
|
<Button
|
||||||
|
onClick={onStop}
|
||||||
|
disabled={!isConnected}
|
||||||
|
className="h-11 px-4 rounded-xl shrink-0"
|
||||||
|
variant="destructive"
|
||||||
|
data-testid="stop-agent"
|
||||||
|
title="Stop generation"
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4 fill-current" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Send / Queue Button */}
|
||||||
|
<Button
|
||||||
|
onClick={onSend}
|
||||||
|
disabled={!canSend}
|
||||||
|
className="h-11 px-4 rounded-xl shrink-0"
|
||||||
|
variant={isProcessing ? 'outline' : 'default'}
|
||||||
|
data-testid="send-message"
|
||||||
|
title={isProcessing ? 'Add to queue' : 'Send message'}
|
||||||
|
>
|
||||||
|
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
<p className="text-[11px] text-muted-foreground mt-2 text-center hidden sm:block">
|
||||||
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
|
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
|
||||||
send,{' '}
|
send,{' '}
|
||||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Shift+Enter</kbd>{' '}
|
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Shift+Enter</kbd>{' '}
|
||||||
|
|||||||
@@ -58,9 +58,10 @@ import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialo
|
|||||||
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
|
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
|
||||||
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
|
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
|
||||||
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
|
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
|
||||||
|
import { MergeWorktreeDialog } from './board-view/dialogs/merge-worktree-dialog';
|
||||||
import { WorktreePanel } from './board-view/worktree-panel';
|
import { WorktreePanel } from './board-view/worktree-panel';
|
||||||
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
|
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
|
||||||
import { COLUMNS } from './board-view/constants';
|
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
|
||||||
import {
|
import {
|
||||||
useBoardFeatures,
|
useBoardFeatures,
|
||||||
useBoardDragDrop,
|
useBoardDragDrop,
|
||||||
@@ -72,8 +73,9 @@ import {
|
|||||||
useBoardPersistence,
|
useBoardPersistence,
|
||||||
useFollowUpState,
|
useFollowUpState,
|
||||||
useSelectionMode,
|
useSelectionMode,
|
||||||
|
useListViewState,
|
||||||
} from './board-view/hooks';
|
} from './board-view/hooks';
|
||||||
import { SelectionActionBar } from './board-view/components';
|
import { SelectionActionBar, ListView } from './board-view/components';
|
||||||
import { MassEditDialog } from './board-view/dialogs';
|
import { MassEditDialog } from './board-view/dialogs';
|
||||||
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
||||||
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
||||||
@@ -147,6 +149,7 @@ export function BoardView() {
|
|||||||
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
|
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
|
||||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||||
|
const [showMergeWorktreeDialog, setShowMergeWorktreeDialog] = useState(false);
|
||||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
|
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
|
||||||
path: string;
|
path: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
@@ -194,6 +197,9 @@ export function BoardView() {
|
|||||||
} = useSelectionMode();
|
} = useSelectionMode();
|
||||||
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
|
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
|
||||||
|
|
||||||
|
// View mode state (kanban vs list)
|
||||||
|
const { viewMode, setViewMode, isListView, sortConfig, setSortColumn } = useListViewState();
|
||||||
|
|
||||||
// Search filter for Kanban cards
|
// Search filter for Kanban cards
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
// Plan approval loading state
|
// Plan approval loading state
|
||||||
@@ -324,20 +330,6 @@ export function BoardView() {
|
|||||||
fetchBranches();
|
fetchBranches();
|
||||||
}, [currentProject, worktreeRefreshKey]);
|
}, [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
|
// Custom collision detection that prioritizes columns over cards
|
||||||
const collisionDetectionStrategy = useCallback((args: any) => {
|
const collisionDetectionStrategy = useCallback((args: any) => {
|
||||||
// First, check if pointer is within a column
|
// First, check if pointer is within a column
|
||||||
@@ -422,6 +414,22 @@ export function BoardView() {
|
|||||||
const selectedWorktreeBranch =
|
const selectedWorktreeBranch =
|
||||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
|
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
|
// Helper function to add and select a worktree
|
||||||
const addAndSelectWorktree = useCallback(
|
const addAndSelectWorktree = useCallback(
|
||||||
(worktreeResult: { path: string; branch: string }) => {
|
(worktreeResult: { path: string; branch: string }) => {
|
||||||
@@ -695,6 +703,7 @@ export function BoardView() {
|
|||||||
model: 'opus' as const,
|
model: 'opus' as const,
|
||||||
thinkingLevel: 'none' as const,
|
thinkingLevel: 'none' as const,
|
||||||
branchName: worktree.branch,
|
branchName: worktree.branch,
|
||||||
|
workMode: 'custom' as const, // Use the worktree's branch
|
||||||
priority: 1, // High priority for PR feedback
|
priority: 1, // High priority for PR feedback
|
||||||
planningMode: 'skip' as const,
|
planningMode: 'skip' as const,
|
||||||
requirePlanApproval: false,
|
requirePlanApproval: false,
|
||||||
@@ -720,10 +729,11 @@ export function BoardView() {
|
|||||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
[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(
|
const handleResolveConflicts = useCallback(
|
||||||
async (worktree: WorktreeInfo) => {
|
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
|
// Create the feature
|
||||||
const featureData = {
|
const featureData = {
|
||||||
@@ -736,6 +746,7 @@ export function BoardView() {
|
|||||||
model: 'opus' as const,
|
model: 'opus' as const,
|
||||||
thinkingLevel: 'none' as const,
|
thinkingLevel: 'none' as const,
|
||||||
branchName: worktree.branch,
|
branchName: worktree.branch,
|
||||||
|
workMode: 'custom' as const, // Use the worktree's branch
|
||||||
priority: 1, // High priority for conflict resolution
|
priority: 1, // High priority for conflict resolution
|
||||||
planningMode: 'skip' as const,
|
planningMode: 'skip' as const,
|
||||||
requirePlanApproval: false,
|
requirePlanApproval: false,
|
||||||
@@ -1119,6 +1130,19 @@ export function BoardView() {
|
|||||||
projectPath: currentProject?.path || null,
|
projectPath: currentProject?.path || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build columnFeaturesMap for ListView
|
||||||
|
const pipelineConfig = currentProject?.path
|
||||||
|
? pipelineConfigByProject[currentProject.path] || null
|
||||||
|
: null;
|
||||||
|
const columnFeaturesMap = useMemo(() => {
|
||||||
|
const columns = getColumnsWithPipeline(pipelineConfig);
|
||||||
|
const map: Record<string, typeof hookFeatures> = {};
|
||||||
|
for (const column of columns) {
|
||||||
|
map[column.id] = getColumnFeatures(column.id as any);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [pipelineConfig, getColumnFeatures]);
|
||||||
|
|
||||||
// Use background hook
|
// Use background hook
|
||||||
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
|
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
|
||||||
currentProject,
|
currentProject,
|
||||||
@@ -1304,8 +1328,8 @@ export function BoardView() {
|
|||||||
isCreatingSpec={isCreatingSpec}
|
isCreatingSpec={isCreatingSpec}
|
||||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
viewMode={viewMode}
|
||||||
completedCount={completedFeatures.length}
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
||||||
@@ -1332,6 +1356,10 @@ export function BoardView() {
|
|||||||
}}
|
}}
|
||||||
onAddressPRComments={handleAddressPRComments}
|
onAddressPRComments={handleAddressPRComments}
|
||||||
onResolveConflicts={handleResolveConflicts}
|
onResolveConflicts={handleResolveConflicts}
|
||||||
|
onMerge={(worktree) => {
|
||||||
|
setSelectedWorktreeForAction(worktree);
|
||||||
|
setShowMergeWorktreeDialog(true);
|
||||||
|
}}
|
||||||
onRemovedWorktrees={handleRemovedWorktrees}
|
onRemovedWorktrees={handleRemovedWorktrees}
|
||||||
runningFeatureIds={runningAutoTasks}
|
runningFeatureIds={runningAutoTasks}
|
||||||
branchCardCounts={branchCardCounts}
|
branchCardCounts={branchCardCounts}
|
||||||
@@ -1344,48 +1372,91 @@ export function BoardView() {
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* View Content - Kanban Board */}
|
{/* View Content - Kanban Board or List View */}
|
||||||
<KanbanBoard
|
{isListView ? (
|
||||||
sensors={sensors}
|
<ListView
|
||||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
columnFeaturesMap={columnFeaturesMap}
|
||||||
onDragStart={handleDragStart}
|
allFeatures={hookFeatures}
|
||||||
onDragEnd={handleDragEnd}
|
sortConfig={sortConfig}
|
||||||
activeFeature={activeFeature}
|
onSortChange={setSortColumn}
|
||||||
getColumnFeatures={getColumnFeatures}
|
actionHandlers={{
|
||||||
backgroundImageStyle={backgroundImageStyle}
|
onEdit: (feature) => setEditingFeature(feature),
|
||||||
backgroundSettings={backgroundSettings}
|
onDelete: (featureId) => handleDeleteFeature(featureId),
|
||||||
onEdit={(feature) => setEditingFeature(feature)}
|
onViewOutput: handleViewOutput,
|
||||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
onVerify: handleVerifyFeature,
|
||||||
onViewOutput={handleViewOutput}
|
onResume: handleResumeFeature,
|
||||||
onVerify={handleVerifyFeature}
|
onForceStop: handleForceStopFeature,
|
||||||
onResume={handleResumeFeature}
|
onManualVerify: handleManualVerify,
|
||||||
onForceStop={handleForceStopFeature}
|
onFollowUp: handleOpenFollowUp,
|
||||||
onManualVerify={handleManualVerify}
|
onImplement: handleStartImplementation,
|
||||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
onComplete: handleCompleteFeature,
|
||||||
onFollowUp={handleOpenFollowUp}
|
onViewPlan: (feature) => setViewPlanFeature(feature),
|
||||||
onComplete={handleCompleteFeature}
|
onApprovePlan: handleOpenApprovalDialog,
|
||||||
onImplement={handleStartImplementation}
|
onSpawnTask: (feature) => {
|
||||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
setSpawnParentFeature(feature);
|
||||||
onApprovePlan={handleOpenApprovalDialog}
|
setShowAddDialog(true);
|
||||||
onSpawnTask={(feature) => {
|
},
|
||||||
setSpawnParentFeature(feature);
|
}}
|
||||||
setShowAddDialog(true);
|
runningAutoTasks={runningAutoTasks}
|
||||||
}}
|
pipelineConfig={pipelineConfig}
|
||||||
featuresWithContext={featuresWithContext}
|
onAddFeature={() => setShowAddDialog(true)}
|
||||||
runningAutoTasks={runningAutoTasks}
|
isSelectionMode={isSelectionMode}
|
||||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
onAddFeature={() => setShowAddDialog(true)}
|
onToggleFeatureSelection={toggleFeatureSelection}
|
||||||
pipelineConfig={
|
onRowClick={(feature) => {
|
||||||
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
|
if (feature.status === 'backlog') {
|
||||||
}
|
setEditingFeature(feature);
|
||||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
} else {
|
||||||
isSelectionMode={isSelectionMode}
|
handleViewOutput(feature);
|
||||||
selectedFeatureIds={selectedFeatureIds}
|
}
|
||||||
onToggleFeatureSelection={toggleFeatureSelection}
|
}}
|
||||||
onToggleSelectionMode={toggleSelectionMode}
|
className="transition-opacity duration-200"
|
||||||
isDragging={activeFeature !== null}
|
/>
|
||||||
onAiSuggest={() => setShowPlanDialog(true)}
|
) : (
|
||||||
/>
|
<KanbanBoard
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
activeFeature={activeFeature}
|
||||||
|
getColumnFeatures={getColumnFeatures}
|
||||||
|
backgroundImageStyle={backgroundImageStyle}
|
||||||
|
backgroundSettings={backgroundSettings}
|
||||||
|
onEdit={(feature) => setEditingFeature(feature)}
|
||||||
|
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||||
|
onViewOutput={handleViewOutput}
|
||||||
|
onVerify={handleVerifyFeature}
|
||||||
|
onResume={handleResumeFeature}
|
||||||
|
onForceStop={handleForceStopFeature}
|
||||||
|
onManualVerify={handleManualVerify}
|
||||||
|
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||||
|
onFollowUp={handleOpenFollowUp}
|
||||||
|
onComplete={handleCompleteFeature}
|
||||||
|
onImplement={handleStartImplementation}
|
||||||
|
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||||
|
onApprovePlan={handleOpenApprovalDialog}
|
||||||
|
onSpawnTask={(feature) => {
|
||||||
|
setSpawnParentFeature(feature);
|
||||||
|
setShowAddDialog(true);
|
||||||
|
}}
|
||||||
|
featuresWithContext={featuresWithContext}
|
||||||
|
runningAutoTasks={runningAutoTasks}
|
||||||
|
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||||
|
onAddFeature={() => setShowAddDialog(true)}
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Selection Action Bar */}
|
{/* Selection Action Bar */}
|
||||||
@@ -1507,7 +1578,7 @@ export function BoardView() {
|
|||||||
open={showPipelineSettings}
|
open={showPipelineSettings}
|
||||||
onClose={() => setShowPipelineSettings(false)}
|
onClose={() => setShowPipelineSettings(false)}
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
pipelineConfig={pipelineConfigByProject[currentProject.path] || null}
|
pipelineConfig={pipelineConfig}
|
||||||
onSave={async (config) => {
|
onSave={async (config) => {
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const result = await api.pipeline.saveConfig(currentProject.path, config);
|
const result = await api.pipeline.saveConfig(currentProject.path, config);
|
||||||
@@ -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 */}
|
{/* Commit Worktree Dialog */}
|
||||||
<CommitWorktreeDialog
|
<CommitWorktreeDialog
|
||||||
open={showCommitWorktreeDialog}
|
open={showCommitWorktreeDialog}
|
||||||
@@ -1650,6 +1750,7 @@ export function BoardView() {
|
|||||||
onOpenChange={setShowCreatePRDialog}
|
onOpenChange={setShowCreatePRDialog}
|
||||||
worktree={selectedWorktreeForAction}
|
worktree={selectedWorktreeForAction}
|
||||||
projectPath={currentProject?.path || null}
|
projectPath={currentProject?.path || null}
|
||||||
|
defaultBaseBranch={selectedWorktreeBranch}
|
||||||
onCreated={(prUrl) => {
|
onCreated={(prUrl) => {
|
||||||
// If a PR was created and we have the worktree branch, update all features on that branch with the PR URL
|
// 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) {
|
if (prUrl && selectedWorktreeForAction?.branch) {
|
||||||
|
|||||||
@@ -1,65 +1,38 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
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 {
|
interface BoardControlsProps {
|
||||||
isMounted: boolean;
|
isMounted: boolean;
|
||||||
onShowBoardBackground: () => void;
|
onShowBoardBackground: () => void;
|
||||||
onShowCompletedModal: () => void;
|
|
||||||
completedCount: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BoardControls({
|
export function BoardControls({ isMounted, onShowBoardBackground }: BoardControlsProps) {
|
||||||
isMounted,
|
|
||||||
onShowBoardBackground,
|
|
||||||
onShowCompletedModal,
|
|
||||||
completedCount,
|
|
||||||
}: BoardControlsProps) {
|
|
||||||
if (!isMounted) return null;
|
if (!isMounted) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-5">
|
||||||
{/* Board Background Button */}
|
{/* Board Background Button */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onShowBoardBackground}
|
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"
|
data-testid="board-background-button"
|
||||||
>
|
>
|
||||||
<ImageIcon className="w-4 h-4" />
|
<ImageIcon className="w-4 h-4" />
|
||||||
</Button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Board Background Settings</p>
|
<p>Board Background Settings</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Slider } from '@/components/ui/slider';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Wand2, GitBranch } from 'lucide-react';
|
||||||
import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
|
|
||||||
import { UsagePopover } from '@/components/usage-popover';
|
import { UsagePopover } from '@/components/usage-popover';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
|
import { AutoModeSettingsPopover } from './dialogs/auto-mode-settings-popover';
|
||||||
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
|
import { WorktreeSettingsPopover } from './dialogs/worktree-settings-popover';
|
||||||
|
import { PlanSettingsPopover } from './dialogs/plan-settings-popover';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { BoardSearchBar } from './board-search-bar';
|
import { BoardSearchBar } from './board-search-bar';
|
||||||
import { BoardControls } from './board-controls';
|
import { BoardControls } from './board-controls';
|
||||||
|
import { ViewToggle, type ViewMode } from './components';
|
||||||
|
import { HeaderMobileMenu } from './header-mobile-menu';
|
||||||
|
|
||||||
|
export type { ViewMode };
|
||||||
|
|
||||||
interface BoardHeaderProps {
|
interface BoardHeaderProps {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
@@ -31,8 +33,9 @@ interface BoardHeaderProps {
|
|||||||
creatingSpecProjectPath?: string;
|
creatingSpecProjectPath?: string;
|
||||||
// Board controls props
|
// Board controls props
|
||||||
onShowBoardBackground: () => void;
|
onShowBoardBackground: () => void;
|
||||||
onShowCompletedModal: () => void;
|
// View toggle props
|
||||||
completedCount: number;
|
viewMode: ViewMode;
|
||||||
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared styles for header control containers
|
// Shared styles for header control containers
|
||||||
@@ -53,13 +56,9 @@ export function BoardHeader({
|
|||||||
isCreatingSpec,
|
isCreatingSpec,
|
||||||
creatingSpecProjectPath,
|
creatingSpecProjectPath,
|
||||||
onShowBoardBackground,
|
onShowBoardBackground,
|
||||||
onShowCompletedModal,
|
viewMode,
|
||||||
completedCount,
|
onViewModeChange,
|
||||||
}: BoardHeaderProps) {
|
}: 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 claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
||||||
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
|
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
|
||||||
@@ -98,22 +97,17 @@ export function BoardHeader({
|
|||||||
[projectPath, setWorktreePanelVisible]
|
[projectPath, setWorktreePanelVisible]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Claude usage tracking visibility logic
|
const isClaudeCliVerified = !!claudeAuthStatus?.authenticated;
|
||||||
// Hide when using API key (only show for Claude Code CLI users)
|
const showClaudeUsage = isClaudeCliVerified;
|
||||||
// Also hide on Windows for now (CLI usage command not supported)
|
|
||||||
const isWindows =
|
|
||||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
|
||||||
const hasClaudeApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
|
||||||
const isClaudeCliVerified =
|
|
||||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
|
||||||
const showClaudeUsage = !hasClaudeApiKey && !isWindows && isClaudeCliVerified;
|
|
||||||
|
|
||||||
// Codex usage tracking visibility logic
|
// Codex usage tracking visibility logic
|
||||||
// Show if Codex is authenticated (CLI or API key)
|
// Show if Codex is authenticated (CLI or API key)
|
||||||
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<BoardSearchBar
|
<BoardSearchBar
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
@@ -122,22 +116,39 @@ export function BoardHeader({
|
|||||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||||
currentProjectPath={projectPath}
|
currentProjectPath={projectPath}
|
||||||
/>
|
/>
|
||||||
<BoardControls
|
{isMounted && <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} />}
|
||||||
isMounted={isMounted}
|
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
||||||
onShowBoardBackground={onShowBoardBackground}
|
|
||||||
onShowCompletedModal={onShowCompletedModal}
|
|
||||||
completedCount={completedCount}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
{/* Usage Popover - show if either provider is authenticated */}
|
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||||
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
{isMounted && !isMobile && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||||
|
|
||||||
|
{/* Mobile view: show hamburger menu with all controls */}
|
||||||
|
{isMounted && isMobile && (
|
||||||
|
<HeaderMobileMenu
|
||||||
|
isWorktreePanelVisible={isWorktreePanelVisible}
|
||||||
|
onWorktreePanelToggle={handleWorktreePanelToggle}
|
||||||
|
maxConcurrency={maxConcurrency}
|
||||||
|
runningAgentsCount={runningAgentsCount}
|
||||||
|
onConcurrencyChange={onConcurrencyChange}
|
||||||
|
isAutoModeRunning={isAutoModeRunning}
|
||||||
|
onAutoModeToggle={onAutoModeToggle}
|
||||||
|
onOpenAutoModeSettings={() => {}}
|
||||||
|
onOpenPlanDialog={onOpenPlanDialog}
|
||||||
|
showClaudeUsage={showClaudeUsage}
|
||||||
|
showCodexUsage={showCodexUsage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop view: show full controls */}
|
||||||
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
|
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
|
||||||
{isMounted && (
|
{isMounted && !isMobile && (
|
||||||
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
||||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||||
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
|
<Label
|
||||||
|
htmlFor="worktrees-toggle"
|
||||||
|
className="text-xs font-medium cursor-pointer whitespace-nowrap"
|
||||||
|
>
|
||||||
Worktree Bar
|
Worktree Bar
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -146,72 +157,20 @@ export function BoardHeader({
|
|||||||
onCheckedChange={handleWorktreePanelToggle}
|
onCheckedChange={handleWorktreePanelToggle}
|
||||||
data-testid="worktrees-toggle"
|
data-testid="worktrees-toggle"
|
||||||
/>
|
/>
|
||||||
<button
|
<WorktreeSettingsPopover
|
||||||
onClick={() => setShowWorktreeSettings(true)}
|
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
|
||||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
|
||||||
title="Worktree Settings"
|
/>
|
||||||
data-testid="worktree-settings-button"
|
|
||||||
>
|
|
||||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</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 */}
|
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||||
{isMounted && (
|
{isMounted && !isMobile && (
|
||||||
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
|
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
|
||||||
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
|
<Label
|
||||||
|
htmlFor="auto-mode-toggle"
|
||||||
|
className="text-xs font-medium cursor-pointer whitespace-nowrap"
|
||||||
|
>
|
||||||
Auto Mode
|
Auto Mode
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -220,52 +179,33 @@ export function BoardHeader({
|
|||||||
onCheckedChange={onAutoModeToggle}
|
onCheckedChange={onAutoModeToggle}
|
||||||
data-testid="auto-mode-toggle"
|
data-testid="auto-mode-toggle"
|
||||||
/>
|
/>
|
||||||
<button
|
<AutoModeSettingsPopover
|
||||||
onClick={() => setShowAutoModeSettings(true)}
|
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||||
title="Auto Mode Settings"
|
maxConcurrency={maxConcurrency}
|
||||||
data-testid="auto-mode-settings-button"
|
runningAgentsCount={runningAgentsCount}
|
||||||
>
|
onConcurrencyChange={onConcurrencyChange}
|
||||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Auto Mode Settings Dialog */}
|
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
|
||||||
<AutoModeSettingsDialog
|
{isMounted && !isMobile && (
|
||||||
open={showAutoModeSettings}
|
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||||
onOpenChange={setShowAutoModeSettings}
|
<button
|
||||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
onClick={onOpenPlanDialog}
|
||||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||||
/>
|
data-testid="plan-backlog-button"
|
||||||
|
>
|
||||||
{/* Plan Button with Settings */}
|
<Wand2 className="w-4 h-4 text-muted-foreground" />
|
||||||
<div className={controlContainerClass} data-testid="plan-button-container">
|
<span className="text-sm font-medium">Plan</span>
|
||||||
<button
|
</button>
|
||||||
onClick={onOpenPlanDialog}
|
<PlanSettingsPopover
|
||||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||||
data-testid="plan-backlog-button"
|
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
|
||||||
>
|
/>
|
||||||
<Wand2 className="w-4 h-4 text-muted-foreground" />
|
</div>
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,3 +2,33 @@ export { KanbanCard } from './kanban-card/kanban-card';
|
|||||||
export { KanbanColumn } from './kanban-column';
|
export { KanbanColumn } from './kanban-column';
|
||||||
export { SelectionActionBar } from './selection-action-bar';
|
export { SelectionActionBar } from './selection-action-bar';
|
||||||
export { EmptyStateCard } from './empty-state-card';
|
export { EmptyStateCard } from './empty-state-card';
|
||||||
|
export { ViewToggle, type ViewMode } from './view-toggle';
|
||||||
|
|
||||||
|
// List view components
|
||||||
|
export {
|
||||||
|
ListHeader,
|
||||||
|
LIST_COLUMNS,
|
||||||
|
getColumnById,
|
||||||
|
getColumnWidth,
|
||||||
|
getColumnAlign,
|
||||||
|
ListRow,
|
||||||
|
getFeatureSortValue,
|
||||||
|
sortFeatures,
|
||||||
|
ListView,
|
||||||
|
getFlatFeatures,
|
||||||
|
getTotalFeatureCount,
|
||||||
|
RowActions,
|
||||||
|
createRowActionHandlers,
|
||||||
|
StatusBadge,
|
||||||
|
getStatusLabel,
|
||||||
|
getStatusOrder,
|
||||||
|
} from './list-view';
|
||||||
|
export type {
|
||||||
|
ListHeaderProps,
|
||||||
|
ListRowProps,
|
||||||
|
ListViewProps,
|
||||||
|
ListViewActionHandlers,
|
||||||
|
RowActionsProps,
|
||||||
|
RowActionHandlers,
|
||||||
|
StatusBadgeProps,
|
||||||
|
} from './list-view';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { Feature, ThinkingLevel } from '@/store/app-store';
|
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
|
||||||
import type { ReasoningEffort } from '@automaker/types';
|
import type { ReasoningEffort } from '@automaker/types';
|
||||||
import { getProviderFromModel } from '@/lib/utils';
|
import { getProviderFromModel } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DEFAULT_MODEL,
|
DEFAULT_MODEL,
|
||||||
} from '@/lib/agent-context-parser';
|
} from '@/lib/agent-context-parser';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { AutoModeEvent } from '@/types/electron';
|
||||||
import {
|
import {
|
||||||
Brain,
|
Brain,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
@@ -71,6 +72,66 @@ export function AgentInfoPanel({
|
|||||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||||
const [isTodosExpanded, setIsTodosExpanded] = 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(() => {
|
useEffect(() => {
|
||||||
const loadContext = async () => {
|
const loadContext = async () => {
|
||||||
@@ -82,6 +143,7 @@ export function AgentInfoPanel({
|
|||||||
|
|
||||||
if (feature.status === 'backlog') {
|
if (feature.status === 'backlog') {
|
||||||
setAgentInfo(null);
|
setAgentInfo(null);
|
||||||
|
setFreshPlanSpec(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +153,21 @@ export function AgentInfoPanel({
|
|||||||
if (!currentProject?.path) return;
|
if (!currentProject?.path) return;
|
||||||
|
|
||||||
if (api.features) {
|
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);
|
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
|
||||||
|
|
||||||
if (result.success && result.content) {
|
if (result.success && result.content) {
|
||||||
@@ -113,13 +190,62 @@ export function AgentInfoPanel({
|
|||||||
|
|
||||||
loadContext();
|
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);
|
const interval = setInterval(loadContext, 3000);
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
|
}, [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
|
// Model/Preset Info for Backlog Cards
|
||||||
if (feature.status === 'backlog') {
|
if (feature.status === 'backlog') {
|
||||||
const provider = getProviderFromModel(feature.model);
|
const provider = getProviderFromModel(feature.model);
|
||||||
@@ -158,7 +284,9 @@ export function AgentInfoPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Agent Info Panel for non-backlog cards
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3 space-y-2 overflow-hidden">
|
<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>
|
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
|
||||||
</div>
|
</div>
|
||||||
{agentInfo.currentPhase && (
|
{agentInfo?.currentPhase && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
|
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
|
||||||
@@ -189,13 +317,13 @@ export function AgentInfoPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task List Progress */}
|
{/* Task List Progress */}
|
||||||
{agentInfo.todos.length > 0 && (
|
{effectiveTodos.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||||
<ListTodo className="w-3 h-3" />
|
<ListTodo className="w-3 h-3" />
|
||||||
<span>
|
<span>
|
||||||
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
|
{effectiveTodos.filter((t) => t.status === 'completed').length}/
|
||||||
{agentInfo.todos.length} tasks
|
{effectiveTodos.length} tasks
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -204,7 +332,7 @@ export function AgentInfoPanel({
|
|||||||
isTodosExpanded ? 'max-h-40' : 'max-h-16'
|
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) => (
|
(todo, idx) => (
|
||||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||||
{todo.status === 'completed' ? (
|
{todo.status === 'completed' ? (
|
||||||
@@ -227,7 +355,7 @@ export function AgentInfoPanel({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{agentInfo.todos.length > 3 && (
|
{effectiveTodos.length > 3 && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -237,7 +365,7 @@ export function AgentInfoPanel({
|
|||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
className="text-[10px] text-muted-foreground/60 pl-4 hover:text-muted-foreground transition-colors cursor-pointer"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -247,7 +375,7 @@ export function AgentInfoPanel({
|
|||||||
{/* Summary for waiting_approval and verified */}
|
{/* Summary for waiting_approval and verified */}
|
||||||
{(feature.status === 'waiting_approval' || feature.status === '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="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 justify-between gap-2">
|
||||||
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
|
<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()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{feature.summary || summary || agentInfo.summary}
|
{feature.summary || summary || agentInfo?.summary}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!feature.summary &&
|
{!feature.summary &&
|
||||||
!summary &&
|
!summary &&
|
||||||
!agentInfo.summary &&
|
!agentInfo?.summary &&
|
||||||
agentInfo.toolCallCount > 0 && (
|
(agentInfo?.toolCallCount ?? 0) > 0 && (
|
||||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
|
<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">
|
<span className="flex items-center gap-1">
|
||||||
<Wrench className="w-2.5 h-2.5" />
|
<Wrench className="w-2.5 h-2.5" />
|
||||||
{agentInfo.toolCallCount} tool calls
|
{agentInfo?.toolCallCount ?? 0} tool calls
|
||||||
</span>
|
</span>
|
||||||
{agentInfo.todos.length > 0 && (
|
{effectiveTodos.length > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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) {
|
}: SelectionActionBarProps) {
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
if (selectedCount === 0) return null;
|
const allSelected = selectedCount === totalCount && totalCount > 0;
|
||||||
|
|
||||||
const allSelected = selectedCount === totalCount;
|
|
||||||
|
|
||||||
const handleDeleteClick = () => {
|
const handleDeleteClick = () => {
|
||||||
setShowDeleteDialog(true);
|
setShowDeleteDialog(true);
|
||||||
@@ -55,7 +53,9 @@ export function SelectionActionBar({
|
|||||||
data-testid="selection-action-bar"
|
data-testid="selection-action-bar"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium text-foreground">
|
<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>
|
</span>
|
||||||
|
|
||||||
<div className="h-4 w-px bg-border" />
|
<div className="h-4 w-px bg-border" />
|
||||||
@@ -65,7 +65,8 @@ export function SelectionActionBar({
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onEdit}
|
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"
|
data-testid="selection-edit-button"
|
||||||
>
|
>
|
||||||
<Pencil className="w-4 h-4 mr-1.5" />
|
<Pencil className="w-4 h-4 mr-1.5" />
|
||||||
@@ -76,7 +77,8 @@ export function SelectionActionBar({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleDeleteClick}
|
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"
|
data-testid="selection-delete-button"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
<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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
|
className="w-full h-full max-w-full max-h-full sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col"
|
||||||
data-testid="agent-output-modal"
|
data-testid="agent-output-modal"
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<div className="flex items-center justify-between pr-8">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||||
)}
|
)}
|
||||||
Agent Output
|
Agent Output
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
<div className="flex items-center gap-1 bg-muted rounded-lg p-1 overflow-x-auto">
|
||||||
{summary && (
|
{summary && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('summary')}
|
onClick={() => setViewMode('summary')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
|
||||||
effectiveViewMode === 'summary'
|
effectiveViewMode === 'summary'
|
||||||
? 'bg-primary/20 text-primary shadow-sm'
|
? 'bg-primary/20 text-primary shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
@@ -332,7 +332,7 @@ export function AgentOutputModal({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('parsed')}
|
onClick={() => setViewMode('parsed')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
|
||||||
effectiveViewMode === 'parsed'
|
effectiveViewMode === 'parsed'
|
||||||
? 'bg-primary/20 text-primary shadow-sm'
|
? 'bg-primary/20 text-primary shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
@@ -344,7 +344,7 @@ export function AgentOutputModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('changes')}
|
onClick={() => setViewMode('changes')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
|
||||||
effectiveViewMode === 'changes'
|
effectiveViewMode === 'changes'
|
||||||
? 'bg-primary/20 text-primary shadow-sm'
|
? 'bg-primary/20 text-primary shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
@@ -356,7 +356,7 @@ export function AgentOutputModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('raw')}
|
onClick={() => setViewMode('raw')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
|
||||||
effectiveViewMode === 'raw'
|
effectiveViewMode === 'raw'
|
||||||
? 'bg-primary/20 text-primary shadow-sm'
|
? 'bg-primary/20 text-primary shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
@@ -380,11 +380,11 @@ export function AgentOutputModal({
|
|||||||
<TaskProgressPanel
|
<TaskProgressPanel
|
||||||
featureId={featureId}
|
featureId={featureId}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
className="flex-shrink-0 mx-1"
|
className="flex-shrink-0 mx-3 my-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{effectiveViewMode === 'changes' ? (
|
{effectiveViewMode === 'changes' ? (
|
||||||
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
|
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||||
{projectPath ? (
|
{projectPath ? (
|
||||||
<GitDiffPanel
|
<GitDiffPanel
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
@@ -401,7 +401,7 @@ export function AgentOutputModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : effectiveViewMode === 'summary' && summary ? (
|
) : effectiveViewMode === 'summary' && summary ? (
|
||||||
<div className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 min-h-[400px] max-h-[60vh] scrollbar-visible">
|
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-card border border-border/50 rounded-lg p-4 scrollbar-visible">
|
||||||
<Markdown>{summary}</Markdown>
|
<Markdown>{summary}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -409,7 +409,7 @@ export function AgentOutputModal({
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh] scrollbar-visible"
|
className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs scrollbar-visible"
|
||||||
>
|
>
|
||||||
{isLoading && !output ? (
|
{isLoading && !output ? (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
|||||||
@@ -1,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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { GitCommit, Loader2 } from 'lucide-react';
|
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -37,7 +38,9 @@ export function CommitWorktreeDialog({
|
|||||||
}: CommitWorktreeDialogProps) {
|
}: CommitWorktreeDialogProps) {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
|
||||||
|
|
||||||
const handleCommit = async () => {
|
const handleCommit = async () => {
|
||||||
if (!worktree || !message.trim()) return;
|
if (!worktree || !message.trim()) return;
|
||||||
@@ -77,11 +80,68 @@ export function CommitWorktreeDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && e.metaKey && !isLoading && message.trim()) {
|
// Prevent commit while loading or while AI is generating a message
|
||||||
|
if (e.key === 'Enter' && e.metaKey && !isLoading && !isGenerating && message.trim()) {
|
||||||
handleCommit();
|
handleCommit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Generate AI commit message when dialog opens (if enabled)
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && worktree) {
|
||||||
|
// Reset state
|
||||||
|
setMessage('');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Only generate AI commit message if enabled
|
||||||
|
if (!enableAiCommitMessages) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const generateMessage = async () => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.generateCommitMessage) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.worktree.generateCommitMessage(worktree.path);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (result.success && result.message) {
|
||||||
|
setMessage(result.message);
|
||||||
|
} else {
|
||||||
|
// Don't show error toast, just log it and leave message empty
|
||||||
|
console.warn('Failed to generate commit message:', result.error);
|
||||||
|
setMessage('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
// Don't show error toast for generation failures
|
||||||
|
console.warn('Error generating commit message:', err);
|
||||||
|
setMessage('');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
generateMessage();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [open, worktree, enableAiCommitMessages]);
|
||||||
|
|
||||||
if (!worktree) return null;
|
if (!worktree) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,10 +166,20 @@ export function CommitWorktreeDialog({
|
|||||||
|
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="commit-message">Commit Message</Label>
|
<Label htmlFor="commit-message" className="flex items-center gap-2">
|
||||||
|
Commit Message
|
||||||
|
{isGenerating && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Sparkles className="w-3 h-3 animate-pulse" />
|
||||||
|
Generating...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="commit-message"
|
id="commit-message"
|
||||||
placeholder="Describe your changes..."
|
placeholder={
|
||||||
|
isGenerating ? 'Generating commit message...' : 'Describe your changes...'
|
||||||
|
}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setMessage(e.target.value);
|
setMessage(e.target.value);
|
||||||
@@ -118,6 +188,7 @@ export function CommitWorktreeDialog({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="min-h-[100px] font-mono text-sm"
|
className="min-h-[100px] font-mono text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
disabled={isGenerating}
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,10 +199,14 @@ export function CommitWorktreeDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isLoading || isGenerating}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCommit} disabled={isLoading || !message.trim()}>
|
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||||
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
|
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -30,6 +31,8 @@ interface CreatePRDialogProps {
|
|||||||
worktree: WorktreeInfo | null;
|
worktree: WorktreeInfo | null;
|
||||||
projectPath: string | null;
|
projectPath: string | null;
|
||||||
onCreated: (prUrl?: string) => void;
|
onCreated: (prUrl?: string) => void;
|
||||||
|
/** Default base branch for the PR (defaults to 'main' if not provided) */
|
||||||
|
defaultBaseBranch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreatePRDialog({
|
export function CreatePRDialog({
|
||||||
@@ -38,10 +41,11 @@ export function CreatePRDialog({
|
|||||||
worktree,
|
worktree,
|
||||||
projectPath,
|
projectPath,
|
||||||
onCreated,
|
onCreated,
|
||||||
|
defaultBaseBranch = 'main',
|
||||||
}: CreatePRDialogProps) {
|
}: CreatePRDialogProps) {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [baseBranch, setBaseBranch] = useState('main');
|
const [baseBranch, setBaseBranch] = useState(defaultBaseBranch);
|
||||||
const [commitMessage, setCommitMessage] = useState('');
|
const [commitMessage, setCommitMessage] = useState('');
|
||||||
const [isDraft, setIsDraft] = useState(false);
|
const [isDraft, setIsDraft] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -49,40 +53,62 @@ export function CreatePRDialog({
|
|||||||
const [prUrl, setPrUrl] = useState<string | null>(null);
|
const [prUrl, setPrUrl] = useState<string | null>(null);
|
||||||
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
||||||
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
|
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
|
// Track whether an operation completed that warrants a refresh
|
||||||
const operationCompletedRef = useRef(false);
|
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
|
// Reset state when dialog opens or worktree changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Reset all state on both open and close
|
||||||
|
resetState();
|
||||||
if (open) {
|
if (open) {
|
||||||
// Reset form fields
|
// Fetch fresh branches when dialog opens
|
||||||
setTitle('');
|
fetchBranches();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}, [open, worktree?.path]);
|
}, [open, worktree?.path, resetState, fetchBranches]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!worktree) return;
|
if (!worktree) return;
|
||||||
@@ -343,15 +369,16 @@ export function CreatePRDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="base-branch">Base Branch</Label>
|
<Label htmlFor="base-branch">Base Branch</Label>
|
||||||
<Input
|
<BranchAutocomplete
|
||||||
id="base-branch"
|
|
||||||
placeholder="main"
|
|
||||||
value={baseBranch}
|
value={baseBranch}
|
||||||
onChange={(e) => setBaseBranch(e.target.value)}
|
onChange={setBaseBranch}
|
||||||
className="font-mono text-sm"
|
branches={branches}
|
||||||
|
placeholder="Select base branch..."
|
||||||
|
disabled={isLoadingBranches}
|
||||||
|
data-testid="base-branch-autocomplete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
<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 { useBoardPersistence } from './use-board-persistence';
|
||||||
export { useFollowUpState } from './use-follow-up-state';
|
export { useFollowUpState } from './use-follow-up-state';
|
||||||
export { useSelectionMode } from './use-selection-mode';
|
export { useSelectionMode } from './use-selection-mode';
|
||||||
|
export { useListViewState } from './use-list-view-state';
|
||||||
|
|||||||
@@ -628,8 +628,8 @@ export function useBoardActions({
|
|||||||
currentProject.path,
|
currentProject.path,
|
||||||
followUpFeature.id,
|
followUpFeature.id,
|
||||||
followUpPrompt,
|
followUpPrompt,
|
||||||
imagePaths
|
imagePaths,
|
||||||
// No worktreePath - server derives from feature.branchName
|
useWorktrees
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -667,6 +667,7 @@ export function useBoardActions({
|
|||||||
setFollowUpPrompt,
|
setFollowUpPrompt,
|
||||||
setFollowUpImagePaths,
|
setFollowUpImagePaths,
|
||||||
setFollowUpPreviewMap,
|
setFollowUpPreviewMap,
|
||||||
|
useWorktrees,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCommitFeature = useCallback(
|
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 { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||||
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||||
import type { PipelineConfig } from '@automaker/types';
|
import type { PipelineConfig } from '@automaker/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
sensors: any;
|
sensors: any;
|
||||||
collisionDetectionStrategy: (args: any) => any;
|
collisionDetectionStrategy: (args: any) => any;
|
||||||
@@ -44,6 +44,8 @@ interface KanbanBoardProps {
|
|||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
onArchiveAllVerified: () => void;
|
onArchiveAllVerified: () => void;
|
||||||
onAddFeature: () => void;
|
onAddFeature: () => void;
|
||||||
|
onShowCompletedModal: () => void;
|
||||||
|
completedCount: number;
|
||||||
pipelineConfig: PipelineConfig | null;
|
pipelineConfig: PipelineConfig | null;
|
||||||
onOpenPipelineSettings?: () => void;
|
onOpenPipelineSettings?: () => void;
|
||||||
// Selection mode props
|
// Selection mode props
|
||||||
@@ -57,6 +59,8 @@ interface KanbanBoardProps {
|
|||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
/** Whether the board is in read-only mode */
|
/** Whether the board is in read-only mode */
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
|
/** Additional className for custom styling (e.g., transition classes) */
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanBoard({
|
export function KanbanBoard({
|
||||||
@@ -86,6 +90,8 @@ export function KanbanBoard({
|
|||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
onArchiveAllVerified,
|
onArchiveAllVerified,
|
||||||
onAddFeature,
|
onAddFeature,
|
||||||
|
onShowCompletedModal,
|
||||||
|
completedCount,
|
||||||
pipelineConfig,
|
pipelineConfig,
|
||||||
onOpenPipelineSettings,
|
onOpenPipelineSettings,
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
@@ -95,6 +101,7 @@ export function KanbanBoard({
|
|||||||
onAiSuggest,
|
onAiSuggest,
|
||||||
isDragging = false,
|
isDragging = false,
|
||||||
isReadOnly = false,
|
isReadOnly = false,
|
||||||
|
className,
|
||||||
}: KanbanBoardProps) {
|
}: KanbanBoardProps) {
|
||||||
// Generate columns including pipeline steps
|
// Generate columns including pipeline steps
|
||||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||||
@@ -108,7 +115,14 @@ export function KanbanBoard({
|
|||||||
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-1 overflow-x-auto px-5 pt-4 pb-4 relative',
|
||||||
|
'transition-opacity duration-200',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={backgroundImageStyle}
|
||||||
|
>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={collisionDetectionStrategy}
|
collisionDetection={collisionDetectionStrategy}
|
||||||
@@ -130,17 +144,36 @@ export function KanbanBoard({
|
|||||||
showBorder={backgroundSettings.columnBorderEnabled}
|
showBorder={backgroundSettings.columnBorderEnabled}
|
||||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||||
headerAction={
|
headerAction={
|
||||||
column.id === 'verified' && columnFeatures.length > 0 ? (
|
column.id === 'verified' ? (
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant="ghost"
|
{columnFeatures.length > 0 && (
|
||||||
size="sm"
|
<Button
|
||||||
className="h-6 px-2 text-xs"
|
variant="ghost"
|
||||||
onClick={onArchiveAllVerified}
|
size="sm"
|
||||||
data-testid="archive-all-verified-button"
|
className="h-6 px-2 text-xs"
|
||||||
>
|
onClick={onArchiveAllVerified}
|
||||||
<Archive className="w-3 h-3 mr-1" />
|
data-testid="archive-all-verified-button"
|
||||||
Complete All
|
>
|
||||||
</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' ? (
|
) : column.id === 'backlog' ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<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"
|
type="button"
|
||||||
onClick={() => onPrioritySelect(1)}
|
onClick={() => onPrioritySelect(1)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
|
||||||
selectedPriority === 1
|
selectedPriority === 1
|
||||||
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
|
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
|
||||||
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
||||||
@@ -30,7 +30,7 @@ export function PrioritySelector({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPrioritySelect(2)}
|
onClick={() => onPrioritySelect(2)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
|
||||||
selectedPriority === 2
|
selectedPriority === 2
|
||||||
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
|
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
|
||||||
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
||||||
@@ -43,7 +43,7 @@ export function PrioritySelector({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPrioritySelect(3)}
|
onClick={() => onPrioritySelect(3)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
|
||||||
selectedPriority === 3
|
selectedPriority === 3
|
||||||
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
|
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
|
||||||
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface BranchSwitchDropdownProps {
|
|||||||
branchFilter: string;
|
branchFilter: string;
|
||||||
isLoadingBranches: boolean;
|
isLoadingBranches: boolean;
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
|
/** When true, renders as a standalone button (not attached to another element) */
|
||||||
|
standalone?: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onFilterChange: (value: string) => void;
|
onFilterChange: (value: string) => void;
|
||||||
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
||||||
@@ -33,6 +35,7 @@ export function BranchSwitchDropdown({
|
|||||||
branchFilter,
|
branchFilter,
|
||||||
isLoadingBranches,
|
isLoadingBranches,
|
||||||
isSwitching,
|
isSwitching,
|
||||||
|
standalone = false,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
onSwitchBranch,
|
onSwitchBranch,
|
||||||
@@ -42,16 +45,18 @@ export function BranchSwitchDropdown({
|
|||||||
<DropdownMenu onOpenChange={onOpenChange}>
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={isSelected ? 'default' : 'outline'}
|
variant={standalone ? 'outline' : isSelected ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-7 w-7 p-0 rounded-none border-r-0',
|
'h-7 w-7 p-0',
|
||||||
isSelected && 'bg-primary text-primary-foreground',
|
!standalone && 'rounded-none border-r-0',
|
||||||
!isSelected && 'bg-secondary/50 hover:bg-secondary'
|
standalone && 'h-8 w-8 shrink-0',
|
||||||
|
!standalone && isSelected && 'bg-primary text-primary-foreground',
|
||||||
|
!standalone && !isSelected && 'bg-secondary/50 hover:bg-secondary'
|
||||||
)}
|
)}
|
||||||
title="Switch branch"
|
title="Switch branch"
|
||||||
>
|
>
|
||||||
<GitBranch className="w-3 h-3" />
|
<GitBranch className={standalone ? 'w-3.5 h-3.5' : 'w-3 h-3'} />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-64">
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
|
|||||||
@@ -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 { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||||
|
export { DevServerLogsPanel } from './dev-server-logs-panel';
|
||||||
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||||
|
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
|
||||||
export { WorktreeTab } from './worktree-tab';
|
export { WorktreeTab } from './worktree-tab';
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Copy,
|
Copy,
|
||||||
|
ScrollText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -44,6 +45,8 @@ interface WorktreeActionsDropdownProps {
|
|||||||
isDevServerRunning: boolean;
|
isDevServerRunning: boolean;
|
||||||
devServerInfo?: DevServerInfo;
|
devServerInfo?: DevServerInfo;
|
||||||
gitRepoStatus: GitRepoStatus;
|
gitRepoStatus: GitRepoStatus;
|
||||||
|
/** When true, renders as a standalone button (not attached to another element) */
|
||||||
|
standalone?: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
@@ -52,10 +55,12 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
|
onMerge: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||||
|
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
hasInitScript: boolean;
|
hasInitScript: boolean;
|
||||||
}
|
}
|
||||||
@@ -71,6 +76,7 @@ export function WorktreeActionsDropdown({
|
|||||||
isDevServerRunning,
|
isDevServerRunning,
|
||||||
devServerInfo,
|
devServerInfo,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
|
standalone = false,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onPull,
|
onPull,
|
||||||
onPush,
|
onPush,
|
||||||
@@ -79,10 +85,12 @@ export function WorktreeActionsDropdown({
|
|||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
|
onMerge,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
onOpenDevServerUrl,
|
onOpenDevServerUrl,
|
||||||
|
onViewDevServerLogs,
|
||||||
onRunInitScript,
|
onRunInitScript,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
@@ -115,15 +123,17 @@ export function WorktreeActionsDropdown({
|
|||||||
<DropdownMenu onOpenChange={onOpenChange}>
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={isSelected ? 'default' : 'outline'}
|
variant={standalone ? 'outline' : isSelected ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-7 w-7 p-0 rounded-l-none',
|
'h-7 w-7 p-0',
|
||||||
isSelected && 'bg-primary text-primary-foreground',
|
!standalone && 'rounded-l-none',
|
||||||
!isSelected && 'bg-secondary/50 hover:bg-secondary'
|
standalone && 'h-8 w-8 shrink-0',
|
||||||
|
!standalone && isSelected && 'bg-primary text-primary-foreground',
|
||||||
|
!standalone && !isSelected && 'bg-secondary/50 hover:bg-secondary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="w-3 h-3" />
|
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-56">
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
@@ -151,6 +161,10 @@ export function WorktreeActionsDropdown({
|
|||||||
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
||||||
Open in Browser
|
Open in Browser
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
||||||
|
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onStopDevServer(worktree)}
|
onClick={() => onStopDevServer(worktree)}
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
@@ -205,21 +219,35 @@ export function WorktreeActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipWrapper>
|
</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 && (
|
{!worktree.isMain && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
tooltipContent={gitOpsDisabledReason}
|
tooltipContent={gitOpsDisabledReason}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => canPerformGitOps && onResolveConflicts(worktree)}
|
onClick={() => canPerformGitOps && onMerge(worktree)}
|
||||||
disabled={!canPerformGitOps}
|
disabled={!canPerformGitOps}
|
||||||
className={cn(
|
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'
|
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||||
Pull & Resolve Conflicts
|
Merge to Main
|
||||||
{!canPerformGitOps && (
|
{!canPerformGitOps && (
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
@@ -320,7 +348,7 @@ export function WorktreeActionsDropdown({
|
|||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{/* Show PR info and Address Comments button if PR exists */}
|
{/* Show PR info and Address Comments button if PR exists */}
|
||||||
{!worktree.isMain && hasPR && worktree.pr && (
|
{hasPR && worktree.pr && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
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 { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -41,10 +41,12 @@ interface WorktreeTabProps {
|
|||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
|
onMerge: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||||
|
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
hasInitScript: boolean;
|
hasInitScript: boolean;
|
||||||
}
|
}
|
||||||
@@ -83,10 +85,12 @@ export function WorktreeTab({
|
|||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
|
onMerge,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
onOpenDevServerUrl,
|
onOpenDevServerUrl,
|
||||||
|
onViewDevServerLogs,
|
||||||
onRunInitScript,
|
onRunInitScript,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
@@ -342,10 +346,12 @@ export function WorktreeTab({
|
|||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
|
onMerge={onMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={onStartDevServer}
|
onStartDevServer={onStartDevServer}
|
||||||
onStopDevServer={onStopDevServer}
|
onStopDevServer={onStopDevServer}
|
||||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||||
|
onViewDevServerLogs={onViewDevServerLogs}
|
||||||
onRunInitScript={onRunInitScript}
|
onRunInitScript={onRunInitScript}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { useWorktrees } from './use-worktrees';
|
export { useWorktrees } from './use-worktrees';
|
||||||
export { useDevServers } from './use-dev-servers';
|
export { useDevServers } from './use-dev-servers';
|
||||||
|
export { useDevServerLogs } from './use-dev-server-logs';
|
||||||
export { useBranches } from './use-branches';
|
export { useBranches } from './use-branches';
|
||||||
export { useWorktreeActions } from './use-worktree-actions';
|
export { useWorktreeActions } from './use-worktree-actions';
|
||||||
export { useRunningFeatures } from './use-running-features';
|
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;
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
|
onMerge: (worktree: WorktreeInfo) => void;
|
||||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||||
runningFeatureIds?: string[];
|
runningFeatureIds?: string[];
|
||||||
features?: FeatureInfo[];
|
features?: FeatureInfo[];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
|||||||
import { cn, pathsEqual } from '@/lib/utils';
|
import { cn, pathsEqual } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||||
import {
|
import {
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
@@ -12,7 +13,13 @@ import {
|
|||||||
useWorktreeActions,
|
useWorktreeActions,
|
||||||
useRunningFeatures,
|
useRunningFeatures,
|
||||||
} from './hooks';
|
} from './hooks';
|
||||||
import { WorktreeTab } from './components';
|
import {
|
||||||
|
WorktreeTab,
|
||||||
|
DevServerLogsPanel,
|
||||||
|
WorktreeMobileDropdown,
|
||||||
|
WorktreeActionsDropdown,
|
||||||
|
BranchSwitchDropdown,
|
||||||
|
} from './components';
|
||||||
|
|
||||||
export function WorktreePanel({
|
export function WorktreePanel({
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -23,6 +30,7 @@ export function WorktreePanel({
|
|||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
|
onMerge,
|
||||||
onRemovedWorktrees,
|
onRemovedWorktrees,
|
||||||
runningFeatureIds = [],
|
runningFeatureIds = [],
|
||||||
features = [],
|
features = [],
|
||||||
@@ -84,6 +92,10 @@ export function WorktreePanel({
|
|||||||
// Track whether init script exists for the project
|
// Track whether init script exists for the project
|
||||||
const [hasInitScript, setHasInitScript] = useState(false);
|
const [hasInitScript, setHasInitScript] = useState(false);
|
||||||
|
|
||||||
|
// Log panel state management
|
||||||
|
const [logPanelOpen, setLogPanelOpen] = useState(false);
|
||||||
|
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
setHasInitScript(false);
|
setHasInitScript(false);
|
||||||
@@ -103,6 +115,8 @@ export function WorktreePanel({
|
|||||||
checkInitScript();
|
checkInitScript();
|
||||||
}, [projectPath]);
|
}, [projectPath]);
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Periodic interval check (5 seconds) to detect branch changes on disk
|
// Periodic interval check (5 seconds) to detect branch changes on disk
|
||||||
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
|
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -164,9 +178,122 @@ export function WorktreePanel({
|
|||||||
[projectPath]
|
[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 mainWorktree = worktrees.find((w) => w.isMain);
|
||||||
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
||||||
|
|
||||||
|
// Mobile view: single dropdown for all worktrees
|
||||||
|
if (isMobile) {
|
||||||
|
// Find the currently selected worktree for the actions menu
|
||||||
|
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w)) || mainWorktree;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||||
|
<WorktreeMobileDropdown
|
||||||
|
worktrees={worktrees}
|
||||||
|
isWorktreeSelected={isWorktreeSelected}
|
||||||
|
hasRunningFeatures={hasRunningFeatures}
|
||||||
|
isActivating={isActivating}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
|
onSelectWorktree={handleSelectWorktree}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Branch switch dropdown for the selected worktree */}
|
||||||
|
{selectedWorktree && (
|
||||||
|
<BranchSwitchDropdown
|
||||||
|
worktree={selectedWorktree}
|
||||||
|
isSelected={true}
|
||||||
|
standalone={true}
|
||||||
|
branches={branches}
|
||||||
|
filteredBranches={filteredBranches}
|
||||||
|
branchFilter={branchFilter}
|
||||||
|
isLoadingBranches={isLoadingBranches}
|
||||||
|
isSwitching={isSwitching}
|
||||||
|
onOpenChange={handleBranchDropdownOpenChange(selectedWorktree)}
|
||||||
|
onFilterChange={setBranchFilter}
|
||||||
|
onSwitchBranch={handleSwitchBranch}
|
||||||
|
onCreateBranch={onCreateBranch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions menu for the selected worktree */}
|
||||||
|
{selectedWorktree && (
|
||||||
|
<WorktreeActionsDropdown
|
||||||
|
worktree={selectedWorktree}
|
||||||
|
isSelected={true}
|
||||||
|
standalone={true}
|
||||||
|
aheadCount={aheadCount}
|
||||||
|
behindCount={behindCount}
|
||||||
|
isPulling={isPulling}
|
||||||
|
isPushing={isPushing}
|
||||||
|
isStartingDevServer={isStartingDevServer}
|
||||||
|
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
||||||
|
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||||
|
gitRepoStatus={gitRepoStatus}
|
||||||
|
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||||
|
onPull={handlePull}
|
||||||
|
onPush={handlePush}
|
||||||
|
onOpenInEditor={handleOpenInEditor}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onCreatePR={onCreatePR}
|
||||||
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onResolveConflicts={onResolveConflicts}
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -209,10 +336,12 @@ export function WorktreePanel({
|
|||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
|
onMerge={onMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
@@ -265,10 +394,12 @@ export function WorktreePanel({
|
|||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
|
onMerge={onMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
@@ -303,6 +434,15 @@ export function WorktreePanel({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Dev Server Logs Panel */}
|
||||||
|
<DevServerLogsPanel
|
||||||
|
open={logPanelOpen}
|
||||||
|
onClose={handleCloseLogPanel}
|
||||||
|
worktree={logPanelWorktree}
|
||||||
|
onStopDevServer={handleStopDevServer}
|
||||||
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export function GraphViewPage() {
|
|||||||
setShowAddDialog(true);
|
setShowAddDialog(true);
|
||||||
}}
|
}}
|
||||||
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
|
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
|
||||||
|
onAddFeature={() => setShowAddDialog(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Edit Feature Dialog */}
|
{/* Edit Feature Dialog */}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useNodesState,
|
useNodesState,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
|
useReactFlow,
|
||||||
SelectionMode,
|
SelectionMode,
|
||||||
ConnectionMode,
|
ConnectionMode,
|
||||||
Node,
|
Node,
|
||||||
@@ -34,7 +35,7 @@ import {
|
|||||||
} from './hooks';
|
} from './hooks';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useDebounceValue } from 'usehooks-ts';
|
import { useDebounceValue } from 'usehooks-ts';
|
||||||
import { SearchX } from 'lucide-react';
|
import { SearchX, Plus } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
// 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;
|
onNodeDoubleClick?: (featureId: string) => void;
|
||||||
nodeActionCallbacks?: NodeActionCallbacks;
|
nodeActionCallbacks?: NodeActionCallbacks;
|
||||||
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
|
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
|
||||||
|
onAddFeature?: () => void;
|
||||||
backgroundStyle?: React.CSSProperties;
|
backgroundStyle?: React.CSSProperties;
|
||||||
backgroundSettings?: BackgroundSettings;
|
backgroundSettings?: BackgroundSettings;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -76,6 +78,7 @@ function GraphCanvasInner({
|
|||||||
onNodeDoubleClick,
|
onNodeDoubleClick,
|
||||||
nodeActionCallbacks,
|
nodeActionCallbacks,
|
||||||
onCreateDependency,
|
onCreateDependency,
|
||||||
|
onAddFeature,
|
||||||
backgroundStyle,
|
backgroundStyle,
|
||||||
backgroundSettings,
|
backgroundSettings,
|
||||||
className,
|
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
|
// MiniMap node color based on status
|
||||||
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
|
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
|
||||||
const data = node.data as TaskNodeData | undefined;
|
const data = node.data as TaskNodeData | undefined;
|
||||||
@@ -321,6 +400,14 @@ function GraphCanvasInner({
|
|||||||
|
|
||||||
<GraphLegend />
|
<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 */}
|
{/* Empty state when all nodes are filtered out */}
|
||||||
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
|
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
|
||||||
<Panel position="top-center" className="mt-20">
|
<Panel position="top-center" className="mt-20">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface GraphViewProps {
|
|||||||
onUpdateFeature?: (featureId: string, updates: Partial<Feature>) => void;
|
onUpdateFeature?: (featureId: string, updates: Partial<Feature>) => void;
|
||||||
onSpawnTask?: (feature: Feature) => void;
|
onSpawnTask?: (feature: Feature) => void;
|
||||||
onDeleteTask?: (feature: Feature) => void;
|
onDeleteTask?: (feature: Feature) => void;
|
||||||
|
onAddFeature?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GraphView({
|
export function GraphView({
|
||||||
@@ -40,6 +41,7 @@ export function GraphView({
|
|||||||
onUpdateFeature,
|
onUpdateFeature,
|
||||||
onSpawnTask,
|
onSpawnTask,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
|
onAddFeature,
|
||||||
}: GraphViewProps) {
|
}: GraphViewProps) {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
|
|
||||||
@@ -212,6 +214,7 @@ export function GraphView({
|
|||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
nodeActionCallbacks={nodeActionCallbacks}
|
nodeActionCallbacks={nodeActionCallbacks}
|
||||||
onCreateDependency={handleCreateDependency}
|
onCreateDependency={handleCreateDependency}
|
||||||
|
onAddFeature={onAddFeature}
|
||||||
backgroundStyle={backgroundImageStyle}
|
backgroundStyle={backgroundImageStyle}
|
||||||
backgroundSettings={backgroundSettings}
|
backgroundSettings={backgroundSettings}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useSearch } from '@tanstack/react-router';
|
import { useSearch } from '@tanstack/react-router';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
@@ -30,6 +30,9 @@ import { PromptCustomizationSection } from './settings-view/prompts';
|
|||||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||||
import type { Project as ElectronProject } from '@/lib/electron';
|
import type { Project as ElectronProject } from '@/lib/electron';
|
||||||
|
|
||||||
|
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||||
|
const LG_BREAKPOINT = 1024;
|
||||||
|
|
||||||
export function SettingsView() {
|
export function SettingsView() {
|
||||||
const {
|
const {
|
||||||
theme,
|
theme,
|
||||||
@@ -41,6 +44,8 @@ export function SettingsView() {
|
|||||||
setEnableDependencyBlocking,
|
setEnableDependencyBlocking,
|
||||||
skipVerificationInAutoMode,
|
skipVerificationInAutoMode,
|
||||||
setSkipVerificationInAutoMode,
|
setSkipVerificationInAutoMode,
|
||||||
|
enableAiCommitMessages,
|
||||||
|
setEnableAiCommitMessages,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
setUseWorktrees,
|
setUseWorktrees,
|
||||||
muteDoneSound,
|
muteDoneSound,
|
||||||
@@ -108,6 +113,33 @@ export function SettingsView() {
|
|||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||||
|
|
||||||
|
// Mobile navigation state - default to showing on desktop, hidden on mobile
|
||||||
|
const [showNavigation, setShowNavigation] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.innerWidth >= LG_BREAKPOINT;
|
||||||
|
}
|
||||||
|
return true; // Default to showing on SSR
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-close navigation on mobile when a section is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth < LG_BREAKPOINT) {
|
||||||
|
setShowNavigation(false);
|
||||||
|
}
|
||||||
|
}, [activeView]);
|
||||||
|
|
||||||
|
// Handle window resize to show/hide navigation appropriately
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (window.innerWidth >= LG_BREAKPOINT) {
|
||||||
|
setShowNavigation(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Render the active section based on current view
|
// Render the active section based on current view
|
||||||
const renderActiveSection = () => {
|
const renderActiveSection = () => {
|
||||||
switch (activeView) {
|
switch (activeView) {
|
||||||
@@ -159,12 +191,14 @@ export function SettingsView() {
|
|||||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||||
defaultPlanningMode={defaultPlanningMode}
|
defaultPlanningMode={defaultPlanningMode}
|
||||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||||
|
enableAiCommitMessages={enableAiCommitMessages}
|
||||||
defaultFeatureModel={defaultFeatureModel}
|
defaultFeatureModel={defaultFeatureModel}
|
||||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||||
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
||||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||||
|
onEnableAiCommitMessagesChange={setEnableAiCommitMessages}
|
||||||
onDefaultFeatureModelChange={setDefaultFeatureModel}
|
onDefaultFeatureModelChange={setDefaultFeatureModel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -196,20 +230,25 @@ export function SettingsView() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
|
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<SettingsHeader />
|
<SettingsHeader
|
||||||
|
showNavigation={showNavigation}
|
||||||
|
onToggleNavigation={() => setShowNavigation(!showNavigation)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Content Area with Sidebar */}
|
{/* Content Area with Sidebar */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Side Navigation - No longer scrolls, just switches views */}
|
{/* Side Navigation - Overlay on mobile, sidebar on desktop */}
|
||||||
<SettingsNavigation
|
<SettingsNavigation
|
||||||
navItems={NAV_ITEMS}
|
navItems={NAV_ITEMS}
|
||||||
activeSection={activeView}
|
activeSection={activeView}
|
||||||
currentProject={currentProject}
|
currentProject={currentProject}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
|
isOpen={showNavigation}
|
||||||
|
onClose={() => setShowNavigation(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content Panel - Shows only the active section */}
|
{/* Content Panel - Shows only the active section */}
|
||||||
<div className="flex-1 overflow-y-auto p-8">
|
<div className="flex-1 overflow-y-auto p-4 lg:p-8">
|
||||||
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
|
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,157 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const ERROR_NO_API = 'Claude usage API not available';
|
||||||
|
const CLAUDE_USAGE_TITLE = 'Claude Usage';
|
||||||
|
const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.';
|
||||||
|
const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.';
|
||||||
|
const CLAUDE_LOGIN_COMMAND = 'claude login';
|
||||||
|
const CLAUDE_NO_USAGE_MESSAGE =
|
||||||
|
'Usage limits are not available yet. Try refreshing if this persists.';
|
||||||
|
const UPDATED_LABEL = 'Updated';
|
||||||
|
const CLAUDE_FETCH_ERROR = 'Failed to fetch usage';
|
||||||
|
const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage';
|
||||||
|
const WARNING_THRESHOLD = 75;
|
||||||
|
const CAUTION_THRESHOLD = 50;
|
||||||
|
const MAX_PERCENTAGE = 100;
|
||||||
|
const REFRESH_INTERVAL_MS = 60_000;
|
||||||
|
const STALE_THRESHOLD_MS = 2 * 60_000;
|
||||||
|
// Using purple/indigo for Claude branding
|
||||||
|
const USAGE_COLOR_CRITICAL = 'bg-red-500';
|
||||||
|
const USAGE_COLOR_WARNING = 'bg-amber-500';
|
||||||
|
const USAGE_COLOR_OK = 'bg-indigo-500';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate color class for a usage percentage
|
||||||
|
*/
|
||||||
|
function getUsageColor(percentage: number): string {
|
||||||
|
if (percentage >= WARNING_THRESHOLD) {
|
||||||
|
return USAGE_COLOR_CRITICAL;
|
||||||
|
}
|
||||||
|
if (percentage >= CAUTION_THRESHOLD) {
|
||||||
|
return USAGE_COLOR_WARNING;
|
||||||
|
}
|
||||||
|
return USAGE_COLOR_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual usage card displaying a usage metric with progress bar
|
||||||
|
*/
|
||||||
|
function UsageCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
percentage,
|
||||||
|
resetText,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
percentage: number;
|
||||||
|
resetText?: string;
|
||||||
|
}) {
|
||||||
|
const safePercentage = Math.min(Math.max(percentage, 0), MAX_PERCENTAGE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">{title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-foreground">{Math.round(safePercentage)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-2 w-full rounded-full bg-secondary/60">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all duration-300',
|
||||||
|
getUsageColor(safePercentage)
|
||||||
|
)}
|
||||||
|
style={{ width: `${safePercentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{resetText && <p className="mt-2 text-xs text-muted-foreground">{resetText}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ClaudeUsageSection() {
|
export function ClaudeUsageSection() {
|
||||||
|
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
|
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const canFetchUsage = !!claudeAuthStatus?.authenticated;
|
||||||
|
// If we have usage data, we can show it even if auth status is unsure
|
||||||
|
const hasUsage = !!claudeUsage;
|
||||||
|
|
||||||
|
const lastUpdatedLabel = claudeUsageLastUpdated
|
||||||
|
? new Date(claudeUsageLastUpdated).toLocaleString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const showAuthWarning =
|
||||||
|
(!canFetchUsage && !hasUsage && !isLoading) ||
|
||||||
|
(error && error.includes('Authentication required'));
|
||||||
|
|
||||||
|
const isStale =
|
||||||
|
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
|
||||||
|
|
||||||
|
const fetchUsage = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.claude) {
|
||||||
|
setError(ERROR_NO_API);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.claude.getUsage();
|
||||||
|
|
||||||
|
if ('error' in result) {
|
||||||
|
// Check for auth errors specifically
|
||||||
|
if (
|
||||||
|
result.message?.includes('Authentication required') ||
|
||||||
|
result.error?.includes('Authentication required')
|
||||||
|
) {
|
||||||
|
// We'll show the auth warning UI instead of a generic error
|
||||||
|
} else {
|
||||||
|
setError(result.message || result.error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setClaudeUsage(result);
|
||||||
|
} catch (fetchError) {
|
||||||
|
const message = fetchError instanceof Error ? fetchError.message : CLAUDE_FETCH_ERROR;
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [setClaudeUsage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initial fetch if authenticated and stale
|
||||||
|
// Compute staleness inside effect to avoid re-running when Date.now() changes
|
||||||
|
const isDataStale =
|
||||||
|
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
|
||||||
|
if (canFetchUsage && isDataStale) {
|
||||||
|
void fetchUsage();
|
||||||
|
}
|
||||||
|
}, [fetchUsage, canFetchUsage, claudeUsageLastUpdated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canFetchUsage) return undefined;
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
void fetchUsage();
|
||||||
|
}, REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [fetchUsage, canFetchUsage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -12,30 +163,73 @@ export function ClaudeUsageSection() {
|
|||||||
>
|
>
|
||||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-indigo-500/20 to-indigo-600/10 flex items-center justify-center border border-indigo-500/20">
|
||||||
<div className="w-5 h-5 rounded-full bg-green-500/50" />
|
<div className="w-5 h-5 rounded-full bg-indigo-500/50" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||||
Claude Usage Tracking
|
{CLAUDE_USAGE_TITLE}
|
||||||
</h2>
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={fetchUsage}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
|
||||||
|
data-testid="refresh-claude-usage"
|
||||||
|
title={CLAUDE_REFRESH_LABEL}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
|
||||||
Track your Claude Code usage limits. Uses the Claude CLI for data.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
{/* Info about CLI requirement */}
|
<div className="p-6 space-y-4">
|
||||||
<div className="rounded-lg bg-secondary/30 p-3 text-xs text-muted-foreground space-y-2 border border-border/50">
|
{showAuthWarning && (
|
||||||
<p>Usage tracking requires Claude Code CLI to be installed and authenticated:</p>
|
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-1">
|
<AlertCircle className="w-5 h-5 text-amber-500 mt-0.5" />
|
||||||
<li>Install Claude Code CLI if not already installed</li>
|
<div className="text-sm text-amber-400">
|
||||||
<li>
|
{CLAUDE_AUTH_WARNING} Run <span className="font-mono">{CLAUDE_LOGIN_COMMAND}</span>.
|
||||||
Run <code className="font-mono bg-muted px-1 rounded">claude login</code> to
|
</div>
|
||||||
authenticate
|
</div>
|
||||||
</li>
|
)}
|
||||||
<li>Usage data will be fetched automatically every ~minute</li>
|
|
||||||
</ol>
|
{error && !showAuthWarning && (
|
||||||
</div>
|
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
|
||||||
|
<div className="text-sm text-red-400">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasUsage && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<UsageCard
|
||||||
|
title="Session Limit"
|
||||||
|
subtitle="5-hour rolling window"
|
||||||
|
percentage={claudeUsage.sessionPercentage}
|
||||||
|
resetText={claudeUsage.sessionResetText}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UsageCard
|
||||||
|
title="Weekly Limit"
|
||||||
|
subtitle="Resets every Thursday"
|
||||||
|
percentage={claudeUsage.weeklyPercentage}
|
||||||
|
resetText={claudeUsage.weeklyResetText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasUsage && !error && !showAuthWarning && !isLoading && (
|
||||||
|
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
||||||
|
{CLAUDE_NO_USAGE_MESSAGE}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lastUpdatedLabel && (
|
||||||
|
<div className="text-[10px] text-muted-foreground text-right">
|
||||||
|
{UPDATED_LABEL} {lastUpdatedLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Settings } from 'lucide-react';
|
import { Settings, PanelLeft, PanelLeftClose } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
interface SettingsHeaderProps {
|
interface SettingsHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
showNavigation?: boolean;
|
||||||
|
onToggleNavigation?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsHeader({
|
export function SettingsHeader({
|
||||||
title = 'Settings',
|
title = 'Settings',
|
||||||
description = 'Configure your API keys and preferences',
|
description = 'Configure your API keys and preferences',
|
||||||
|
showNavigation,
|
||||||
|
onToggleNavigation,
|
||||||
}: SettingsHeaderProps) {
|
}: SettingsHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -18,21 +23,39 @@ export function SettingsHeader({
|
|||||||
'bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl'
|
'bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="px-8 py-6">
|
<div className="px-4 py-4 lg:px-8 lg:py-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3 lg:gap-4">
|
||||||
|
{/* Mobile menu toggle button - only visible on mobile */}
|
||||||
|
{onToggleNavigation && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleNavigation}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden"
|
||||||
|
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
|
||||||
|
>
|
||||||
|
{showNavigation ? (
|
||||||
|
<PanelLeftClose className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<PanelLeft className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-12 h-12 rounded-2xl flex items-center justify-center',
|
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
|
||||||
'bg-gradient-to-br from-brand-500 to-brand-600',
|
'bg-gradient-to-br from-brand-500 to-brand-600',
|
||||||
'shadow-lg shadow-brand-500/25',
|
'shadow-lg shadow-brand-500/25',
|
||||||
'ring-1 ring-white/10'
|
'ring-1 ring-white/10'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Settings className="w-6 h-6 text-white" />
|
<Settings className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground tracking-tight">{title}</h1>
|
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
|
||||||
<p className="text-sm text-muted-foreground/80 mt-0.5">{description}</p>
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronRight, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import type { NavigationItem, NavigationGroup } from '../config/navigation';
|
import type { NavigationItem, NavigationGroup } from '../config/navigation';
|
||||||
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation';
|
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation';
|
||||||
@@ -13,6 +14,8 @@ interface SettingsNavigationProps {
|
|||||||
activeSection: SettingsViewId;
|
activeSection: SettingsViewId;
|
||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
onNavigate: (sectionId: SettingsViewId) => void;
|
onNavigate: (sectionId: SettingsViewId) => void;
|
||||||
|
isOpen?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavButton({
|
function NavButton({
|
||||||
@@ -167,75 +170,116 @@ export function SettingsNavigation({
|
|||||||
activeSection,
|
activeSection,
|
||||||
currentProject,
|
currentProject,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
isOpen = true,
|
||||||
|
onClose,
|
||||||
}: SettingsNavigationProps) {
|
}: SettingsNavigationProps) {
|
||||||
|
// On mobile, only show when isOpen is true
|
||||||
|
// On desktop (lg+), always show regardless of isOpen
|
||||||
|
// The desktop visibility is handled by CSS, but we need to render on mobile only when open
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<>
|
||||||
className={cn(
|
{/* Mobile backdrop overlay - only shown when isOpen is true on mobile */}
|
||||||
'hidden lg:block w-64 shrink-0 overflow-y-auto',
|
{isOpen && (
|
||||||
'border-r border-border/50',
|
<div
|
||||||
'bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl'
|
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="settings-nav-backdrop"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<div className="sticky top-0 p-4 space-y-1">
|
|
||||||
{/* Global Settings Groups */}
|
|
||||||
{GLOBAL_NAV_GROUPS.map((group, groupIndex) => (
|
|
||||||
<div key={group.label}>
|
|
||||||
{/* Group divider (except for first group) */}
|
|
||||||
{groupIndex > 0 && <div className="my-3 border-t border-border/50" />}
|
|
||||||
|
|
||||||
{/* Group Label */}
|
{/* Navigation sidebar */}
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
<nav
|
||||||
{group.label}
|
className={cn(
|
||||||
|
// Mobile: fixed position overlay with slide transition
|
||||||
|
'fixed inset-y-0 left-0 w-72 z-30',
|
||||||
|
'transition-transform duration-200 ease-out',
|
||||||
|
// Hide on mobile when closed, show when open
|
||||||
|
isOpen ? 'translate-x-0' : '-translate-x-full',
|
||||||
|
// Desktop: relative position in layout, always visible
|
||||||
|
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
|
||||||
|
'shrink-0 overflow-y-auto',
|
||||||
|
'border-r border-border/50',
|
||||||
|
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl',
|
||||||
|
// Desktop background
|
||||||
|
'lg:from-card/80 lg:via-card/60 lg:to-card/40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Mobile close button */}
|
||||||
|
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border/50">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Navigation</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Close navigation menu"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky top-0 p-4 space-y-1">
|
||||||
|
{/* Global Settings Groups */}
|
||||||
|
{GLOBAL_NAV_GROUPS.map((group, groupIndex) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
{/* Group divider (except for first group) */}
|
||||||
|
{groupIndex > 0 && <div className="my-3 border-t border-border/50" />}
|
||||||
|
|
||||||
|
{/* Group Label */}
|
||||||
|
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group Items */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.items.map((item) =>
|
||||||
|
item.subItems ? (
|
||||||
|
<NavItemWithSubItems
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
activeSection={activeSection}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<NavButton
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isActive={activeSection === item.id}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Group Items */}
|
{/* Project Settings - only show when a project is selected */}
|
||||||
<div className="space-y-1">
|
{currentProject && (
|
||||||
{group.items.map((item) =>
|
<>
|
||||||
item.subItems ? (
|
{/* Divider */}
|
||||||
<NavItemWithSubItems
|
<div className="my-3 border-t border-border/50" />
|
||||||
key={item.id}
|
|
||||||
item={item}
|
{/* Project Settings Label */}
|
||||||
activeSection={activeSection}
|
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
||||||
onNavigate={onNavigate}
|
Project Settings
|
||||||
/>
|
</div>
|
||||||
) : (
|
|
||||||
|
{/* Project Settings Items */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{PROJECT_NAV_ITEMS.map((item) => (
|
||||||
<NavButton
|
<NavButton
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
isActive={activeSection === item.id}
|
isActive={activeSection === item.id}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
))}
|
</div>
|
||||||
|
</nav>
|
||||||
{/* Project Settings - only show when a project is selected */}
|
</>
|
||||||
{currentProject && (
|
|
||||||
<>
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="my-3 border-t border-border/50" />
|
|
||||||
|
|
||||||
{/* Project Settings Label */}
|
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
|
||||||
Project Settings
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Project Settings Items */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{PROJECT_NAV_ITEMS.map((item) => (
|
|
||||||
<NavButton
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
isActive={activeSection === item.id}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ScrollText,
|
ScrollText,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
FastForward,
|
FastForward,
|
||||||
|
Sparkles,
|
||||||
Cpu,
|
Cpu,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -31,12 +32,14 @@ interface FeatureDefaultsSectionProps {
|
|||||||
skipVerificationInAutoMode: boolean;
|
skipVerificationInAutoMode: boolean;
|
||||||
defaultPlanningMode: PlanningMode;
|
defaultPlanningMode: PlanningMode;
|
||||||
defaultRequirePlanApproval: boolean;
|
defaultRequirePlanApproval: boolean;
|
||||||
|
enableAiCommitMessages: boolean;
|
||||||
defaultFeatureModel: PhaseModelEntry;
|
defaultFeatureModel: PhaseModelEntry;
|
||||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||||
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
||||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||||
|
onEnableAiCommitMessagesChange: (value: boolean) => void;
|
||||||
onDefaultFeatureModelChange: (value: PhaseModelEntry) => void;
|
onDefaultFeatureModelChange: (value: PhaseModelEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +49,14 @@ export function FeatureDefaultsSection({
|
|||||||
skipVerificationInAutoMode,
|
skipVerificationInAutoMode,
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
|
enableAiCommitMessages,
|
||||||
defaultFeatureModel,
|
defaultFeatureModel,
|
||||||
onDefaultSkipTestsChange,
|
onDefaultSkipTestsChange,
|
||||||
onEnableDependencyBlockingChange,
|
onEnableDependencyBlockingChange,
|
||||||
onSkipVerificationInAutoModeChange,
|
onSkipVerificationInAutoModeChange,
|
||||||
onDefaultPlanningModeChange,
|
onDefaultPlanningModeChange,
|
||||||
onDefaultRequirePlanApprovalChange,
|
onDefaultRequirePlanApprovalChange,
|
||||||
|
onEnableAiCommitMessagesChange,
|
||||||
onDefaultFeatureModelChange,
|
onDefaultFeatureModelChange,
|
||||||
}: FeatureDefaultsSectionProps) {
|
}: FeatureDefaultsSectionProps) {
|
||||||
return (
|
return (
|
||||||
@@ -281,6 +286,34 @@ export function FeatureDefaultsSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
|
{/* AI Commit Messages Setting */}
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
<Checkbox
|
||||||
|
id="enable-ai-commit-messages"
|
||||||
|
checked={enableAiCommitMessages}
|
||||||
|
onCheckedChange={(checked) => onEnableAiCommitMessagesChange(checked === true)}
|
||||||
|
className="mt-1"
|
||||||
|
data-testid="enable-ai-commit-messages-checkbox"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="enable-ai-commit-messages"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 text-brand-500" />
|
||||||
|
Generate AI commit messages
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
When enabled, opening the commit dialog will automatically generate a commit message
|
||||||
|
using AI based on your staged or unstaged changes. You can configure the model used in
|
||||||
|
Model Defaults.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const QUICK_TASKS: PhaseConfig[] = [
|
|||||||
label: 'Image Descriptions',
|
label: 'Image Descriptions',
|
||||||
description: 'Analyzes and describes context images',
|
description: 'Analyzes and describes context images',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'commitMessageModel',
|
||||||
|
label: 'Commit Messages',
|
||||||
|
description: 'Generates git commit messages from diffs',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const VALIDATION_TASKS: PhaseConfig[] = [
|
const VALIDATION_TASKS: PhaseConfig[] = [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import type {
|
import type {
|
||||||
ModelAlias,
|
ModelAlias,
|
||||||
CursorModelId,
|
CursorModelId,
|
||||||
@@ -165,8 +166,13 @@ export function PhaseModelSelector({
|
|||||||
codexModelsLoading,
|
codexModelsLoading,
|
||||||
fetchCodexModels,
|
fetchCodexModels,
|
||||||
dynamicOpencodeModels,
|
dynamicOpencodeModels,
|
||||||
|
opencodeModelsLoading,
|
||||||
|
fetchOpencodeModels,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
|
// Detect mobile devices to use inline expansion instead of nested popovers
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Extract model and thinking/reasoning levels from value
|
// Extract model and thinking/reasoning levels from value
|
||||||
const selectedModel = value.model;
|
const selectedModel = value.model;
|
||||||
const selectedThinkingLevel = value.thinkingLevel || 'none';
|
const selectedThinkingLevel = value.thinkingLevel || 'none';
|
||||||
@@ -181,6 +187,15 @@ export function PhaseModelSelector({
|
|||||||
}
|
}
|
||||||
}, [codexModels.length, codexModelsLoading, fetchCodexModels]);
|
}, [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
|
// Close expanded group when trigger scrolls out of view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const triggerElement = expandedTriggerRef.current;
|
const triggerElement = expandedTriggerRef.current;
|
||||||
@@ -585,6 +600,107 @@ export function PhaseModelSelector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Model supports reasoning - show popover with reasoning effort options
|
// Model supports reasoning - show popover with reasoning effort options
|
||||||
|
// On mobile, render inline expansion instead of nested popover
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div key={model.id}>
|
||||||
|
<CommandItem
|
||||||
|
value={model.label}
|
||||||
|
onSelect={() => setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))}
|
||||||
|
className="group flex items-center justify-between py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<OpenAIIcon
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0',
|
||||||
|
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col truncate">
|
||||||
|
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||||
|
{model.label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{isSelected && currentReasoning !== 'none'
|
||||||
|
? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}`
|
||||||
|
: model.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
|
||||||
|
isFavorite
|
||||||
|
? 'text-yellow-500 opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFavoriteModel(model.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
|
||||||
|
</Button>
|
||||||
|
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
|
||||||
|
{/* Inline reasoning effort options on mobile */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="pl-6 pr-2 pb-2 space-y-1">
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
Reasoning Effort
|
||||||
|
</div>
|
||||||
|
{REASONING_EFFORT_LEVELS.map((effort) => (
|
||||||
|
<button
|
||||||
|
key={effort}
|
||||||
|
onClick={() => {
|
||||||
|
onChange({
|
||||||
|
model: model.id as CodexModelId,
|
||||||
|
reasoningEffort: effort,
|
||||||
|
});
|
||||||
|
setExpandedCodexModel(null);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||||
|
'hover:bg-accent cursor-pointer transition-colors',
|
||||||
|
isSelected && currentReasoning === effort && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="font-medium text-xs">{REASONING_EFFORT_LABELS[effort]}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{effort === 'none' && 'No reasoning capability'}
|
||||||
|
{effort === 'minimal' && 'Minimal reasoning'}
|
||||||
|
{effort === 'low' && 'Light reasoning'}
|
||||||
|
{effort === 'medium' && 'Moderate reasoning'}
|
||||||
|
{effort === 'high' && 'Deep reasoning'}
|
||||||
|
{effort === 'xhigh' && 'Maximum reasoning'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && currentReasoning === effort && (
|
||||||
|
<Check className="h-3.5 w-3.5 text-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: Use nested popover
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
@@ -829,6 +945,106 @@ export function PhaseModelSelector({
|
|||||||
const isExpanded = expandedClaudeModel === model.id;
|
const isExpanded = expandedClaudeModel === model.id;
|
||||||
const currentThinking = isSelected ? selectedThinkingLevel : 'none';
|
const currentThinking = isSelected ? selectedThinkingLevel : 'none';
|
||||||
|
|
||||||
|
// On mobile, render inline expansion instead of nested popover
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div key={model.id}>
|
||||||
|
<CommandItem
|
||||||
|
value={model.label}
|
||||||
|
onSelect={() => setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))}
|
||||||
|
className="group flex items-center justify-between py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<AnthropicIcon
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0',
|
||||||
|
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col truncate">
|
||||||
|
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||||
|
{model.label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{isSelected && currentThinking !== 'none'
|
||||||
|
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
|
||||||
|
: model.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
|
||||||
|
isFavorite
|
||||||
|
? 'text-yellow-500 opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFavoriteModel(model.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
|
||||||
|
</Button>
|
||||||
|
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
|
||||||
|
{/* Inline thinking level options on mobile */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="pl-6 pr-2 pb-2 space-y-1">
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
Thinking Level
|
||||||
|
</div>
|
||||||
|
{THINKING_LEVELS.map((level) => (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => {
|
||||||
|
onChange({
|
||||||
|
model: model.id as ModelAlias,
|
||||||
|
thinkingLevel: level,
|
||||||
|
});
|
||||||
|
setExpandedClaudeModel(null);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||||
|
'hover:bg-accent cursor-pointer transition-colors',
|
||||||
|
isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="font-medium text-xs">{THINKING_LEVEL_LABELS[level]}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{level === 'none' && 'No extended thinking'}
|
||||||
|
{level === 'low' && 'Light reasoning (1k tokens)'}
|
||||||
|
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||||
|
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||||
|
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && currentThinking === level && (
|
||||||
|
<Check className="h-3.5 w-3.5 text-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: Use nested popover
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
@@ -963,6 +1179,90 @@ export function PhaseModelSelector({
|
|||||||
? 'Reasoning Mode'
|
? 'Reasoning Mode'
|
||||||
: 'Capacity Options';
|
: 'Capacity Options';
|
||||||
|
|
||||||
|
// On mobile, render inline expansion instead of nested popover
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div key={group.baseId}>
|
||||||
|
<CommandItem
|
||||||
|
value={group.label}
|
||||||
|
onSelect={() => setExpandedGroup(isExpanded ? null : group.baseId)}
|
||||||
|
className="group flex items-center justify-between py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<CursorIcon
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0',
|
||||||
|
groupIsSelected ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col truncate">
|
||||||
|
<span className={cn('truncate font-medium', groupIsSelected && 'text-primary')}>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{selectedVariant ? `Selected: ${selectedVariant.label}` : group.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
{groupIsSelected && !isExpanded && (
|
||||||
|
<Check className="h-4 w-4 text-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
|
||||||
|
{/* Inline variant options on mobile */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="pl-6 pr-2 pb-2 space-y-1">
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
{variantTypeLabel}
|
||||||
|
</div>
|
||||||
|
{group.variants.map((variant) => (
|
||||||
|
<button
|
||||||
|
key={variant.id}
|
||||||
|
onClick={() => {
|
||||||
|
onChange({ model: variant.id });
|
||||||
|
setExpandedGroup(null);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||||
|
'hover:bg-accent cursor-pointer transition-colors',
|
||||||
|
selectedModel === variant.id && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="font-medium text-xs">{variant.label}</span>
|
||||||
|
{variant.description && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{variant.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{variant.badge && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{variant.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedModel === variant.id && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: Use nested popover
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={group.baseId}
|
key={group.baseId}
|
||||||
@@ -1111,6 +1411,7 @@ export function PhaseModelSelector({
|
|||||||
className="w-[320px] p-0"
|
className="w-[320px] p-0"
|
||||||
align={align}
|
align={align}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
onTouchMove={(e) => e.stopPropagation()}
|
||||||
onPointerDownOutside={(e) => {
|
onPointerDownOutside={(e) => {
|
||||||
// Only prevent close if clicking inside a nested popover (thinking level panel)
|
// Only prevent close if clicking inside a nested popover (thinking level panel)
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
@@ -1123,7 +1424,7 @@ export function PhaseModelSelector({
|
|||||||
<CommandInput placeholder="Search models..." />
|
<CommandInput placeholder="Search models..." />
|
||||||
<CommandList
|
<CommandList
|
||||||
ref={commandListRef}
|
ref={commandListRef}
|
||||||
className="max-h-[300px] overflow-y-auto overscroll-contain"
|
className="max-h-[300px] overflow-y-auto overscroll-contain touch-pan-y"
|
||||||
>
|
>
|
||||||
<CommandEmpty>No model found.</CommandEmpty>
|
<CommandEmpty>No model found.</CommandEmpty>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Info,
|
Info,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
GitCommitHorizontal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { PromptCustomization, CustomPrompt } from '@automaker/types';
|
import type { PromptCustomization, CustomPrompt } from '@automaker/types';
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
DEFAULT_AGENT_PROMPTS,
|
DEFAULT_AGENT_PROMPTS,
|
||||||
DEFAULT_BACKLOG_PLAN_PROMPTS,
|
DEFAULT_BACKLOG_PLAN_PROMPTS,
|
||||||
DEFAULT_ENHANCEMENT_PROMPTS,
|
DEFAULT_ENHANCEMENT_PROMPTS,
|
||||||
|
DEFAULT_COMMIT_MESSAGE_PROMPTS,
|
||||||
} from '@automaker/prompts';
|
} from '@automaker/prompts';
|
||||||
|
|
||||||
interface PromptCustomizationSectionProps {
|
interface PromptCustomizationSectionProps {
|
||||||
@@ -219,7 +221,7 @@ export function PromptCustomizationSection({
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid grid-cols-4 w-full">
|
<TabsList className="grid grid-cols-5 w-full">
|
||||||
<TabsTrigger value="auto-mode" className="gap-2">
|
<TabsTrigger value="auto-mode" className="gap-2">
|
||||||
<Bot className="w-4 h-4" />
|
<Bot className="w-4 h-4" />
|
||||||
Auto Mode
|
Auto Mode
|
||||||
@@ -236,6 +238,10 @@ export function PromptCustomizationSection({
|
|||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
Enhancement
|
Enhancement
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="commit-message" className="gap-2">
|
||||||
|
<GitCommitHorizontal className="w-4 h-4" />
|
||||||
|
Commit
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Auto Mode Tab */}
|
{/* Auto Mode Tab */}
|
||||||
@@ -443,6 +449,34 @@ export function PromptCustomizationSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Commit Message Tab */}
|
||||||
|
<TabsContent value="commit-message" className="space-y-6 mt-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Commit Message Prompts</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resetToDefaults('commitMessage')}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
Reset Section
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PromptField
|
||||||
|
label="System Prompt"
|
||||||
|
description="Instructions for generating git commit messages from diffs. The AI will receive the git diff and generate a conventional commit message."
|
||||||
|
defaultValue={DEFAULT_COMMIT_MESSAGE_PROMPTS.systemPrompt}
|
||||||
|
customValue={promptCustomization?.commitMessage?.systemPrompt}
|
||||||
|
onCustomValueChange={(value) =>
|
||||||
|
updatePrompt('commitMessage', 'systemPrompt', value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
58
apps/ui/src/hooks/use-media-query.ts
Normal file
58
apps/ui/src/hooks/use-media-query.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if a media query matches
|
||||||
|
* @param query - The media query string (e.g., '(max-width: 768px)')
|
||||||
|
* @returns boolean indicating if the media query matches
|
||||||
|
*/
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track if this is the initial mount to avoid redundant setMatches call
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia(query);
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
setMatches(e.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only sync state when query changes after initial mount
|
||||||
|
// (initial mount already has correct value from useState initializer)
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
} else {
|
||||||
|
setMatches(mediaQuery.matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for changes
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if the device is mobile (screen width <= 768px)
|
||||||
|
* @returns boolean indicating if the device is mobile
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return useMediaQuery('(max-width: 768px)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if the device is tablet or smaller (screen width <= 1024px)
|
||||||
|
* @returns boolean indicating if the device is tablet or smaller
|
||||||
|
*/
|
||||||
|
export function useIsTablet(): boolean {
|
||||||
|
return useMediaQuery('(max-width: 1024px)');
|
||||||
|
}
|
||||||
102
apps/ui/src/hooks/use-provider-auth-init.ts
Normal file
102
apps/ui/src/hooks/use-provider-auth-init.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useSetupStore, type ClaudeAuthMethod, type CodexAuthMethod } from '@/store/setup-store';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
|
||||||
|
const logger = createLogger('ProviderAuthInit');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to initialize Claude and Codex authentication statuses on app startup.
|
||||||
|
* This ensures that usage tracking information is available in the board header
|
||||||
|
* without needing to visit the settings page first.
|
||||||
|
*/
|
||||||
|
export function useProviderAuthInit() {
|
||||||
|
const { setClaudeAuthStatus, setCodexAuthStatus, claudeAuthStatus, codexAuthStatus } =
|
||||||
|
useSetupStore();
|
||||||
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
const refreshStatuses = useCallback(async () => {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
|
||||||
|
// 1. Claude Auth Status
|
||||||
|
try {
|
||||||
|
const result = await api.setup.getClaudeStatus();
|
||||||
|
if (result.success && result.auth) {
|
||||||
|
// Cast to extended type that includes server-added fields
|
||||||
|
const auth = result.auth as typeof result.auth & {
|
||||||
|
oauthTokenValid?: boolean;
|
||||||
|
apiKeyValid?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validMethods: ClaudeAuthMethod[] = [
|
||||||
|
'oauth_token_env',
|
||||||
|
'oauth_token',
|
||||||
|
'api_key',
|
||||||
|
'api_key_env',
|
||||||
|
'credentials_file',
|
||||||
|
'cli_authenticated',
|
||||||
|
'none',
|
||||||
|
];
|
||||||
|
|
||||||
|
const method = validMethods.includes(auth.method as ClaudeAuthMethod)
|
||||||
|
? (auth.method as ClaudeAuthMethod)
|
||||||
|
: ((auth.authenticated ? 'api_key' : 'none') as ClaudeAuthMethod);
|
||||||
|
|
||||||
|
setClaudeAuthStatus({
|
||||||
|
authenticated: auth.authenticated,
|
||||||
|
method,
|
||||||
|
hasCredentialsFile: auth.hasCredentialsFile ?? false,
|
||||||
|
oauthTokenValid: !!(
|
||||||
|
auth.oauthTokenValid ||
|
||||||
|
auth.hasStoredOAuthToken ||
|
||||||
|
auth.hasEnvOAuthToken
|
||||||
|
),
|
||||||
|
apiKeyValid: !!(auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey),
|
||||||
|
hasEnvOAuthToken: !!auth.hasEnvOAuthToken,
|
||||||
|
hasEnvApiKey: !!auth.hasEnvApiKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to init Claude auth status:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Codex Auth Status
|
||||||
|
try {
|
||||||
|
const result = await api.setup.getCodexStatus();
|
||||||
|
if (result.success && result.auth) {
|
||||||
|
const auth = result.auth;
|
||||||
|
|
||||||
|
const validMethods: CodexAuthMethod[] = [
|
||||||
|
'api_key_env',
|
||||||
|
'api_key',
|
||||||
|
'cli_authenticated',
|
||||||
|
'none',
|
||||||
|
];
|
||||||
|
|
||||||
|
const method = validMethods.includes(auth.method as CodexAuthMethod)
|
||||||
|
? (auth.method as CodexAuthMethod)
|
||||||
|
: ((auth.authenticated ? 'api_key' : 'none') as CodexAuthMethod);
|
||||||
|
|
||||||
|
setCodexAuthStatus({
|
||||||
|
authenticated: auth.authenticated,
|
||||||
|
method,
|
||||||
|
hasAuthFile: auth.hasAuthFile ?? false,
|
||||||
|
hasApiKey: auth.hasApiKey ?? false,
|
||||||
|
hasEnvApiKey: auth.hasEnvApiKey ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to init Codex auth status:', error);
|
||||||
|
}
|
||||||
|
}, [setClaudeAuthStatus, setCodexAuthStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only initialize once per session if not already set
|
||||||
|
if (initialized.current || (claudeAuthStatus !== null && codexAuthStatus !== null)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialized.current = true;
|
||||||
|
|
||||||
|
void refreshStatuses();
|
||||||
|
}, [refreshStatuses, claudeAuthStatus, codexAuthStatus]);
|
||||||
|
}
|
||||||
@@ -524,7 +524,7 @@ export interface AutoModeAPI {
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
imagePaths?: string[],
|
imagePaths?: string[],
|
||||||
worktreePath?: string
|
useWorktrees?: boolean
|
||||||
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
|
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
|
||||||
commitFeature: (
|
commitFeature: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
@@ -1440,13 +1440,19 @@ function createMockSetupAPI(): SetupAPI {
|
|||||||
// Mock Worktree API implementation
|
// Mock Worktree API implementation
|
||||||
function createMockWorktreeAPI(): WorktreeAPI {
|
function createMockWorktreeAPI(): WorktreeAPI {
|
||||||
return {
|
return {
|
||||||
mergeFeature: async (projectPath: string, featureId: string, options?: object) => {
|
mergeFeature: async (
|
||||||
|
projectPath: string,
|
||||||
|
branchName: string,
|
||||||
|
worktreePath: string,
|
||||||
|
options?: object
|
||||||
|
) => {
|
||||||
console.log('[Mock] Merging feature:', {
|
console.log('[Mock] Merging feature:', {
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
branchName,
|
||||||
|
worktreePath,
|
||||||
options,
|
options,
|
||||||
});
|
});
|
||||||
return { success: true, mergedBranch: `feature/${featureId}` };
|
return { success: true, mergedBranch: branchName };
|
||||||
},
|
},
|
||||||
|
|
||||||
getInfo: async (projectPath: string, featureId: string) => {
|
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) => {
|
push: async (worktreePath: string, force?: boolean) => {
|
||||||
console.log('[Mock] Pushing worktree:', { worktreePath, force });
|
console.log('[Mock] Pushing worktree:', { worktreePath, force });
|
||||||
return {
|
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) => {
|
getPRInfo: async (worktreePath: string, branchName: string) => {
|
||||||
console.log('[Mock] Getting PR info:', { worktreePath, branchName });
|
console.log('[Mock] Getting PR info:', { worktreePath, branchName });
|
||||||
return {
|
return {
|
||||||
@@ -2089,7 +2119,7 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
imagePaths?: string[],
|
imagePaths?: string[],
|
||||||
worktreePath?: string
|
useWorktrees?: boolean
|
||||||
) => {
|
) => {
|
||||||
if (mockRunningFeatures.has(featureId)) {
|
if (mockRunningFeatures.has(featureId)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -511,7 +511,53 @@ type EventType =
|
|||||||
| 'ideation:analysis'
|
| 'ideation:analysis'
|
||||||
| 'worktree:init-started'
|
| 'worktree:init-started'
|
||||||
| 'worktree:init-output'
|
| '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;
|
type EventCallback = (payload: unknown) => void;
|
||||||
|
|
||||||
@@ -1606,14 +1652,14 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
imagePaths?: string[],
|
imagePaths?: string[],
|
||||||
worktreePath?: string
|
useWorktrees?: boolean
|
||||||
) =>
|
) =>
|
||||||
this.post('/api/auto-mode/follow-up-feature', {
|
this.post('/api/auto-mode/follow-up-feature', {
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
prompt,
|
prompt,
|
||||||
imagePaths,
|
imagePaths,
|
||||||
worktreePath,
|
useWorktrees,
|
||||||
}),
|
}),
|
||||||
commitFeature: (projectPath: string, featureId: string, worktreePath?: string) =>
|
commitFeature: (projectPath: string, featureId: string, worktreePath?: string) =>
|
||||||
this.post('/api/auto-mode/commit-feature', {
|
this.post('/api/auto-mode/commit-feature', {
|
||||||
@@ -1660,8 +1706,12 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
// Worktree API
|
// Worktree API
|
||||||
worktree: WorktreeAPI = {
|
worktree: WorktreeAPI = {
|
||||||
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
|
mergeFeature: (
|
||||||
this.post('/api/worktree/merge', { projectPath, featureId, options }),
|
projectPath: string,
|
||||||
|
branchName: string,
|
||||||
|
worktreePath: string,
|
||||||
|
options?: object
|
||||||
|
) => this.post('/api/worktree/merge', { projectPath, branchName, worktreePath, options }),
|
||||||
getInfo: (projectPath: string, featureId: string) =>
|
getInfo: (projectPath: string, featureId: string) =>
|
||||||
this.post('/api/worktree/info', { projectPath, featureId }),
|
this.post('/api/worktree/info', { projectPath, featureId }),
|
||||||
getStatus: (projectPath: string, featureId: string) =>
|
getStatus: (projectPath: string, featureId: string) =>
|
||||||
@@ -1683,6 +1733,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}),
|
}),
|
||||||
commit: (worktreePath: string, message: string) =>
|
commit: (worktreePath: string, message: string) =>
|
||||||
this.post('/api/worktree/commit', { worktreePath, message }),
|
this.post('/api/worktree/commit', { worktreePath, message }),
|
||||||
|
generateCommitMessage: (worktreePath: string) =>
|
||||||
|
this.post('/api/worktree/generate-commit-message', { worktreePath }),
|
||||||
push: (worktreePath: string, force?: boolean) =>
|
push: (worktreePath: string, force?: boolean) =>
|
||||||
this.post('/api/worktree/push', { worktreePath, force }),
|
this.post('/api/worktree/push', { worktreePath, force }),
|
||||||
createPR: (worktreePath: string, options?: any) =>
|
createPR: (worktreePath: string, options?: any) =>
|
||||||
@@ -1698,8 +1750,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }),
|
pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }),
|
||||||
checkoutBranch: (worktreePath: string, branchName: string) =>
|
checkoutBranch: (worktreePath: string, branchName: string) =>
|
||||||
this.post('/api/worktree/checkout-branch', { worktreePath, branchName }),
|
this.post('/api/worktree/checkout-branch', { worktreePath, branchName }),
|
||||||
listBranches: (worktreePath: string) =>
|
listBranches: (worktreePath: string, includeRemote?: boolean) =>
|
||||||
this.post('/api/worktree/list-branches', { worktreePath }),
|
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }),
|
||||||
switchBranch: (worktreePath: string, branchName: string) =>
|
switchBranch: (worktreePath: string, branchName: string) =>
|
||||||
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
|
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
|
||||||
openInEditor: (worktreePath: string, editorCommand?: string) =>
|
openInEditor: (worktreePath: string, editorCommand?: string) =>
|
||||||
@@ -1712,6 +1764,24 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
||||||
stopDevServer: (worktreePath: string) => this.post('/api/worktree/stop-dev', { worktreePath }),
|
stopDevServer: (worktreePath: string) => this.post('/api/worktree/stop-dev', { worktreePath }),
|
||||||
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
|
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) =>
|
getPRInfo: (worktreePath: string, branchName: string) =>
|
||||||
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
|
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
|
||||||
// Init script methods
|
// Init script methods
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
|
|||||||
|
|
||||||
export interface Feature extends Omit<
|
export interface Feature extends Omit<
|
||||||
BaseFeature,
|
BaseFeature,
|
||||||
'steps' | 'imagePaths' | 'textFilePaths' | 'status'
|
'steps' | 'imagePaths' | 'textFilePaths' | 'status' | 'planSpec'
|
||||||
> {
|
> {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -354,6 +354,7 @@ export interface Feature extends Omit<
|
|||||||
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
||||||
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
||||||
prUrl?: string; // UI-specific: Pull request URL
|
prUrl?: string; // UI-specific: Pull request URL
|
||||||
|
planSpec?: PlanSpec; // Explicit planSpec type to override BaseFeature's index signature
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parsed task from spec (for spec and full planning modes)
|
// Parsed task from spec (for spec and full planning modes)
|
||||||
@@ -536,6 +537,7 @@ export interface AppState {
|
|||||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
||||||
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
|
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
|
||||||
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
|
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
|
||||||
|
enableAiCommitMessages: boolean; // When true, auto-generate commit messages using AI when opening commit dialog
|
||||||
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
|
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
|
||||||
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
|
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
|
||||||
|
|
||||||
@@ -599,6 +601,10 @@ export interface AppState {
|
|||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
authMethod?: string;
|
authMethod?: string;
|
||||||
}>; // Cached providers
|
}>; // 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
|
// Claude Agent SDK Settings
|
||||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||||
@@ -937,6 +943,7 @@ export interface AppActions {
|
|||||||
setDefaultSkipTests: (skip: boolean) => void;
|
setDefaultSkipTests: (skip: boolean) => void;
|
||||||
setEnableDependencyBlocking: (enabled: boolean) => void;
|
setEnableDependencyBlocking: (enabled: boolean) => void;
|
||||||
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
|
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
|
||||||
|
setEnableAiCommitMessages: (enabled: boolean) => Promise<void>;
|
||||||
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||||
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
@@ -1179,6 +1186,9 @@ export interface AppActions {
|
|||||||
}>
|
}>
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
|
// OpenCode Models actions
|
||||||
|
fetchOpencodeModels: (forceRefresh?: boolean) => Promise<void>;
|
||||||
|
|
||||||
// Init Script State actions (keyed by projectPath::branch to support concurrent scripts)
|
// Init Script State actions (keyed by projectPath::branch to support concurrent scripts)
|
||||||
setInitScriptState: (
|
setInitScriptState: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
@@ -1224,6 +1234,7 @@ const initialState: AppState = {
|
|||||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||||
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
|
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
|
||||||
|
enableAiCommitMessages: true, // Default to enabled (auto-generate commit messages)
|
||||||
planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch)
|
planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch)
|
||||||
addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults)
|
addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults)
|
||||||
useWorktrees: true, // Default to enabled (git worktree isolation)
|
useWorktrees: true, // Default to enabled (git worktree isolation)
|
||||||
@@ -1249,6 +1260,10 @@ const initialState: AppState = {
|
|||||||
dynamicOpencodeModels: [], // Empty until fetched from OpenCode CLI
|
dynamicOpencodeModels: [], // Empty until fetched from OpenCode CLI
|
||||||
enabledDynamicModelIds: [], // Empty until user enables dynamic models
|
enabledDynamicModelIds: [], // Empty until user enables dynamic models
|
||||||
cachedOpencodeProviders: [], // Empty until fetched from OpenCode CLI
|
cachedOpencodeProviders: [], // Empty until fetched from OpenCode CLI
|
||||||
|
opencodeModelsLoading: false,
|
||||||
|
opencodeModelsError: null,
|
||||||
|
opencodeModelsLastFetched: null,
|
||||||
|
opencodeModelsLastFailedAt: null,
|
||||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||||
mcpServers: [], // No MCP servers configured by default
|
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');
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
await syncSettingsToServer();
|
await syncSettingsToServer();
|
||||||
},
|
},
|
||||||
|
setEnableAiCommitMessages: async (enabled) => {
|
||||||
|
const previous = get().enableAiCommitMessages;
|
||||||
|
set({ enableAiCommitMessages: enabled });
|
||||||
|
// Sync to server settings file
|
||||||
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
|
const ok = await syncSettingsToServer();
|
||||||
|
if (!ok) {
|
||||||
|
logger.error('Failed to sync enableAiCommitMessages setting to server - reverting');
|
||||||
|
set({ enableAiCommitMessages: previous });
|
||||||
|
}
|
||||||
|
},
|
||||||
setPlanUseSelectedWorktreeBranch: async (enabled) => {
|
setPlanUseSelectedWorktreeBranch: async (enabled) => {
|
||||||
const previous = get().planUseSelectedWorktreeBranch;
|
const previous = get().planUseSelectedWorktreeBranch;
|
||||||
set({ planUseSelectedWorktreeBranch: enabled });
|
set({ planUseSelectedWorktreeBranch: enabled });
|
||||||
@@ -3236,6 +3262,65 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
codexModelsLastFetched: Date.now(),
|
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
|
// Pipeline actions
|
||||||
setPipelineConfig: (projectPath, config) => {
|
setPipelineConfig: (projectPath, config) => {
|
||||||
set({
|
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 {
|
export interface WorktreeAPI {
|
||||||
// Merge feature worktree changes back to main branch
|
// Merge worktree branch into main and clean up
|
||||||
mergeFeature: (
|
mergeFeature: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
branchName: string,
|
||||||
|
worktreePath: string,
|
||||||
options?: {
|
options?: {
|
||||||
squash?: boolean;
|
squash?: boolean;
|
||||||
commitMessage?: string;
|
message?: string;
|
||||||
squashMessage?: string;
|
|
||||||
}
|
}
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -770,6 +770,13 @@ export interface WorktreeAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// Generate an AI commit message from git diff
|
||||||
|
generateCommitMessage: (worktreePath: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
// Push a worktree branch to remote
|
// Push a worktree branch to remote
|
||||||
push: (
|
push: (
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
@@ -851,8 +858,11 @@ export interface WorktreeAPI {
|
|||||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// List all local branches
|
// List branches (local and optionally remote)
|
||||||
listBranches: (worktreePath: string) => Promise<{
|
listBranches: (
|
||||||
|
worktreePath: string,
|
||||||
|
includeRemote?: boolean
|
||||||
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
result?: {
|
result?: {
|
||||||
currentBranch: string;
|
currentBranch: string;
|
||||||
@@ -978,6 +988,43 @@ export interface WorktreeAPI {
|
|||||||
error?: string;
|
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
|
// Get PR info and comments for a branch
|
||||||
getPRInfo: (
|
getPRInfo: (
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ services:
|
|||||||
# - ~/.local/share/opencode:/home/automaker/.local/share/opencode
|
# - ~/.local/share/opencode:/home/automaker/.local/share/opencode
|
||||||
# - ~/.config/opencode:/home/automaker/.config/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:
|
environment:
|
||||||
# Set root directory for all projects and file operations
|
# Set root directory for all projects and file operations
|
||||||
# Users can only create/open projects within this directory
|
# Users can only create/open projects within this directory
|
||||||
@@ -32,3 +35,8 @@ services:
|
|||||||
# Extract your Cursor token with: ./scripts/get-cursor-token.sh
|
# Extract your Cursor token with: ./scripts/get-cursor-token.sh
|
||||||
# Then set it here or in your .env file:
|
# Then set it here or in your .env file:
|
||||||
# - CURSOR_API_KEY=${CURSOR_API_KEY:-}
|
# - 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
|
chown -R automaker:automaker /home/automaker/.cache/opencode
|
||||||
chmod -R 700 /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
|
# 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
|
# 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
|
# 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