Merge origin/main into feat/cursor-cli

Merges latest main branch changes including:
- MCP server support and configuration
- Pipeline configuration system
- Prompt customization settings
- GitHub issue comments in validation
- Auth middleware improvements
- Various UI/UX improvements

All Cursor CLI features preserved:
- Multi-provider support (Claude + Cursor)
- Model override capabilities
- Phase model configuration
- Provider tabs in settings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-31 01:22:18 +01:00
163 changed files with 15300 additions and 1045 deletions

View File

@@ -52,6 +52,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
import { Textarea } from '@/components/ui/textarea';
export function AgentView() {
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
@@ -74,7 +75,7 @@ export function AgentView() {
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
// Input ref for auto-focus
const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Ref for quick create session function from SessionManager
const quickCreateSessionRef = useRef<(() => Promise<void>) | null>(null);
@@ -369,13 +370,24 @@ export function AgentView() {
[processDroppedFiles]
);
const handleKeyPress = (e: React.KeyboardEvent) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const adjustTextareaHeight = useCallback(() => {
const textarea = inputRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}, []);
useEffect(() => {
adjustTextareaHeight();
}, [input, adjustTextareaHeight]);
const handleClearChat = async () => {
if (!confirm('Are you sure you want to clear this conversation?')) return;
await clearHistory();
@@ -879,7 +891,7 @@ export function AgentView() {
onDrop={handleDrop}
>
<div className="flex-1 relative">
<Input
<Textarea
ref={inputRef}
placeholder={
isDragOver
@@ -890,12 +902,13 @@ export function AgentView() {
}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
disabled={!isConnected}
data-testid="agent-input"
rows={1}
className={cn(
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
'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',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
'border-primary/30',
@@ -1001,7 +1014,11 @@ export function AgentView() {
<p className="text-[11px] text-muted-foreground mt-2 text-center">
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>{' '}
for new line
</p>
</div>
)}

View File

@@ -8,6 +8,7 @@ import {
} from '@dnd-kit/core';
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { AutoModeEvent } from '@/types/electron';
import type { ModelAlias, CursorModelId, BacklogPlanResult } from '@automaker/types';
import { pathsEqual } from '@/lib/utils';
@@ -36,6 +37,7 @@ import {
FollowUpDialog,
PlanApprovalDialog,
} from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
@@ -60,9 +62,6 @@ import {
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
/** Delay before starting a newly created feature to allow state to settle */
const FEATURE_CREATION_SETTLE_DELAY_MS = 500;
export function BoardView() {
const {
currentProject,
@@ -88,7 +87,10 @@ export function BoardView() {
enableDependencyBlocking,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
} = useAppStore();
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
const shortcuts = useKeyboardShortcutsConfig();
const {
features: hookFeatures,
@@ -133,6 +135,9 @@ export function BoardView() {
const [pendingBacklogPlan, setPendingBacklogPlan] = useState<BacklogPlanResult | null>(null);
const [isGeneratingPlan, setIsGeneratingPlan] = useState(false);
// Pipeline settings dialog state
const [showPipelineSettings, setShowPipelineSettings] = useState(false);
// Follow-up state hook
const {
showFollowUpDialog,
@@ -204,6 +209,25 @@ export function BoardView() {
setFeaturesWithContext,
});
// Load pipeline config when project changes
useEffect(() => {
if (!currentProject?.path) return;
const loadPipelineConfig = async () => {
try {
const api = getHttpApiClient();
const result = await api.pipeline.getConfig(currentProject.path);
if (result.success && result.config) {
setPipelineConfig(currentProject.path, result.config);
}
} catch (error) {
console.error('[Board] Failed to load pipeline config:', error);
}
};
loadPipelineConfig();
}, [currentProject?.path, setPipelineConfig]);
// Auto mode hook
const autoMode = useAutoMode();
// Get runningTasks from the hook (scoped to current project)
@@ -461,23 +485,22 @@ export function BoardView() {
requirePlanApproval: false,
};
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
// Find the newly created feature and start it
// We need to wait a moment for the feature to be created
setTimeout(async () => {
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === 'backlog' &&
f.description.includes(`PR #${prNumber}`)
);
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
}
}, FEATURE_CREATION_SETTLE_DELAY_MS);
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
},
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
@@ -503,26 +526,49 @@ export function BoardView() {
requirePlanApproval: false,
};
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
// Find the newly created feature and start it
setTimeout(async () => {
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === 'backlog' &&
f.description.includes('Pull latest from origin/main')
);
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
}
}, FEATURE_CREATION_SETTLE_DELAY_MS);
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
},
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Handler for "Make" button - creates a feature and immediately starts it
const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
},
[handleAddFeature, handleStartImplementation]
);
// Client-side auto mode: periodically check for backlog items and move them to in-progress
// Use a ref to track the latest auto mode state so async operations always check the current value
const autoModeRunningRef = useRef(autoMode.isRunning);
@@ -1075,6 +1121,10 @@ export function BoardView() {
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
/>
) : (
<GraphView
@@ -1137,6 +1187,7 @@ export function BoardView() {
}
}}
onAdd={handleAddFeature}
onAddAndStart={handleAddAndStartFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
@@ -1186,6 +1237,22 @@ export function BoardView() {
}}
/>
{/* Pipeline Settings Dialog */}
<PipelineSettingsDialog
open={showPipelineSettings}
onClose={() => setShowPipelineSettings(false)}
projectPath={currentProject.path}
pipelineConfig={pipelineConfigByProject[currentProject.path] || null}
onSave={async (config) => {
const api = getHttpApiClient();
const result = await api.pipeline.saveConfig(currentProject.path, config);
if (!result.success) {
throw new Error(result.error || 'Failed to save pipeline config');
}
setPipelineConfig(currentProject.path, config);
}}
/>
{/* Follow-Up Prompt Dialog */}
<FollowUpDialog
open={showFollowUpDialog}

View File

@@ -7,6 +7,7 @@ import { Plus, Bot, Wand2 } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
interface BoardHeaderProps {
projectName: string;
@@ -34,12 +35,18 @@ export function BoardHeader({
isMounted,
}: BoardHeaderProps) {
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
// Also hide on Windows for now (CLI usage command not supported)
// Only show if CLI has been verified/authenticated
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows;
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const isCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">

View File

@@ -1,6 +1,5 @@
import React, { memo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import React, { memo, useLayoutEffect, useState } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Feature, useAppStore } from '@/store/app-store';
@@ -10,6 +9,25 @@ import { CardContentSections } from './card-content-sections';
import { AgentInfoPanel } from './agent-info-panel';
import { CardActions } from './card-actions';
function getCardBorderStyle(enabled: boolean, opacity: number): React.CSSProperties {
if (!enabled) {
return { borderWidth: '0px', borderColor: 'transparent' };
}
if (opacity !== 100) {
return {
borderWidth: '1px',
borderColor: `color-mix(in oklch, var(--border) ${opacity}%, transparent)`,
};
}
return {};
}
function getCursorClass(isOverlay: boolean | undefined, isDraggable: boolean): string {
if (isOverlay) return 'cursor-grabbing';
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
return 'cursor-default';
}
interface KanbanCardProps {
feature: Feature;
onEdit: () => void;
@@ -35,6 +53,7 @@ interface KanbanCardProps {
glassmorphism?: boolean;
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
isOverlay?: boolean;
}
export const KanbanCard = memo(function KanbanCard({
@@ -62,64 +81,63 @@ export const KanbanCard = memo(function KanbanCard({
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
isOverlay,
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
const [isLifted, setIsLifted] = useState(false);
useLayoutEffect(() => {
if (isOverlay) {
requestAnimationFrame(() => {
setIsLifted(true);
});
}
}, [isOverlay]);
const isDraggable =
feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: feature.id,
disabled: !isDraggable,
disabled: !isDraggable || isOverlay,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
const dndStyle = {
opacity: isDragging ? 0.5 : undefined,
};
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = '0px';
(borderStyle as Record<string, string>).borderColor = 'transparent';
} else if (cardBorderOpacity !== 100) {
(borderStyle as Record<string, string>).borderWidth = '1px';
(borderStyle as Record<string, string>).borderColor =
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
const cardElement = (
const wrapperClasses = cn(
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
getCursorClass(isOverlay, isDraggable),
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
);
const isInteractive = !isDragging && !isOverlay;
const hasError = feature.error && !isCurrentAutoTask;
const innerCardClasses = cn(
'kanban-card-content h-full relative shadow-sm',
'transition-all duration-200 ease-out',
isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
!isCurrentAutoTask &&
cardBorderEnabled &&
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg'
);
const renderCardContent = () => (
<Card
ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle}
className={cn(
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
'transition-all duration-200 ease-out',
// Premium shadow system
'shadow-sm hover:shadow-md hover:shadow-black/10',
// Subtle lift on hover
'hover:-translate-y-0.5',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
!isDragging && 'bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
// Error state - using CSS variable
feature.error &&
!isCurrentAutoTask &&
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
!isDraggable && 'cursor-default'
)}
data-testid={`kanban-card-${feature.id}`}
style={isCurrentAutoTask ? undefined : cardStyle}
className={innerCardClasses}
onDoubleClick={onEdit}
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Background overlay with opacity */}
{!isDragging && (
{(!isDragging || isOverlay) && (
<div
className={cn(
'absolute inset-0 rounded-xl bg-card -z-10',
@@ -185,10 +203,20 @@ export const KanbanCard = memo(function KanbanCard({
</Card>
);
// Wrap with animated border when in progress
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper">{cardElement}</div>;
}
return cardElement;
return (
<div
ref={setNodeRef}
style={dndStyle}
{...attributes}
{...(isDraggable ? listeners : {})}
className={wrapperClasses}
data-testid={`kanban-card-${feature.id}`}
>
{isCurrentAutoTask ? (
<div className="animated-border-wrapper">{renderCardContent()}</div>
) : (
renderCardContent()
)}
</div>
);
});

View File

@@ -1,14 +1,28 @@
import { Feature } from '@/store/app-store';
import type { Feature } from '@/store/app-store';
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
export type ColumnId = Feature['status'];
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
export interface Column {
id: FeatureStatusWithPipeline;
title: string;
colorClass: string;
isPipelineStep?: boolean;
pipelineStepId?: string;
}
// Base columns (start)
const BASE_COLUMNS: Column[] = [
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
{
id: 'in_progress',
title: 'In Progress',
colorClass: 'bg-[var(--status-in-progress)]',
},
];
// End columns (after pipeline)
const END_COLUMNS: Column[] = [
{
id: 'waiting_approval',
title: 'Waiting Approval',
@@ -20,3 +34,58 @@ export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
colorClass: 'bg-[var(--status-success)]',
},
];
// Static COLUMNS for backwards compatibility (no pipeline)
export const COLUMNS: Column[] = [...BASE_COLUMNS, ...END_COLUMNS];
/**
* Generate columns including pipeline steps
*/
export function getColumnsWithPipeline(pipelineConfig: PipelineConfig | null): Column[] {
const pipelineSteps = pipelineConfig?.steps || [];
if (pipelineSteps.length === 0) {
return COLUMNS;
}
// Sort steps by order
const sortedSteps = [...pipelineSteps].sort((a, b) => a.order - b.order);
// Convert pipeline steps to columns (filter out invalid steps)
const pipelineColumns: Column[] = sortedSteps
.filter((step) => step && step.id) // Only include valid steps with an id
.map((step) => ({
id: `pipeline_${step.id}` as FeatureStatusWithPipeline,
title: step.name || 'Pipeline Step',
colorClass: step.colorClass || 'bg-[var(--status-in-progress)]',
isPipelineStep: true,
pipelineStepId: step.id,
}));
return [...BASE_COLUMNS, ...pipelineColumns, ...END_COLUMNS];
}
/**
* Get the index where pipeline columns should be inserted
* (after in_progress, before waiting_approval)
*/
export function getPipelineInsertIndex(): number {
return BASE_COLUMNS.length;
}
/**
* Check if a status is a pipeline status
*/
export function isPipelineStatus(status: string): boolean {
return status.startsWith('pipeline_');
}
/**
* Extract step ID from a pipeline status
*/
export function getStepIdFromStatus(status: string): string | null {
if (!isPipelineStatus(status)) {
return null;
}
return status.replace('pipeline_', '');
}

View File

@@ -19,7 +19,14 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import { MessageSquare, Settings2, SlidersHorizontal, Sparkles, ChevronDown } from 'lucide-react';
import {
MessageSquare,
Settings2,
SlidersHorizontal,
Sparkles,
ChevronDown,
Play,
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
@@ -57,25 +64,28 @@ import {
} from '@automaker/dependency-resolver';
import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types';
type FeatureData = {
title: string;
category: string;
description: string;
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
};
interface AddFeatureDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (feature: {
title: string;
category: string;
description: string;
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
skipTests: boolean;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
}) => void;
onAdd: (feature: FeatureData) => void;
onAddAndStart?: (feature: FeatureData) => void;
categorySuggestions: string[];
branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
@@ -94,6 +104,7 @@ export function AddFeatureDialog({
open,
onOpenChange,
onAdd,
onAddAndStart,
categorySuggestions,
branchSuggestions,
branchCardCounts,
@@ -188,16 +199,16 @@ export function AddFeatureDialog({
allFeatures,
]);
const handleAdd = () => {
const buildFeatureData = (): FeatureData | null => {
if (!newFeature.description.trim()) {
setDescriptionError(true);
return;
return null;
}
// Validate branch selection when "other branch" is selected
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
toast.error('Please select a branch name');
return;
return null;
}
const category = newFeature.category || 'Uncategorized';
@@ -235,7 +246,7 @@ export function AddFeatureDialog({
}
}
onAdd({
return {
title: newFeature.title,
category,
description: finalDescription,
@@ -251,9 +262,10 @@ export function AddFeatureDialog({
requirePlanApproval,
// In spawn mode, automatically add parent as dependency
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
});
};
};
// Reset form
const resetForm = () => {
setNewFeature({
title: '',
category: '',
@@ -276,6 +288,20 @@ export function AddFeatureDialog({
onOpenChange(false);
};
const handleAction = (actionFn?: (data: FeatureData) => void) => {
if (!actionFn) return;
const featureData = buildFeatureData();
if (!featureData) return;
actionFn(featureData);
resetForm();
};
const handleAdd = () => handleAction(onAdd);
const handleAddAndStart = () => handleAction(onAddAndStart);
const handleDialogClose = (open: boolean) => {
onOpenChange(open);
if (!open) {
@@ -606,6 +632,17 @@ export function AddFeatureDialog({
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{onAddAndStart && (
<Button
onClick={handleAddAndStart}
variant="secondary"
data-testid="confirm-add-and-start-feature"
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
>
<Play className="w-4 h-4 mr-2" />
Make
</Button>
)}
<HotkeyButton
onClick={handleAdd}
hotkey={{ key: 'Enter', cmdCtrl: true }}

View File

@@ -23,6 +23,8 @@ interface AgentOutputModalProps {
featureStatus?: string;
/** Called when a number key (0-9) is pressed while the modal is open */
onNumberKeyPress?: (key: string) => void;
/** Project path - if not provided, falls back to window.__currentProject for backward compatibility */
projectPath?: string;
}
type ViewMode = 'parsed' | 'raw' | 'changes';
@@ -34,6 +36,7 @@ export function AgentOutputModal({
featureId,
featureStatus,
onNumberKeyPress,
projectPath: projectPathProp,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
@@ -62,19 +65,19 @@ export function AgentOutputModal({
setIsLoading(true);
try {
// Get current project path from store (we'll need to pass this)
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) {
// Use projectPath prop if provided, otherwise fall back to window.__currentProject for backward compatibility
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path;
if (!resolvedProjectPath) {
setIsLoading(false);
return;
}
projectPathRef.current = currentProject.path;
setProjectPath(currentProject.path);
projectPathRef.current = resolvedProjectPath;
setProjectPath(resolvedProjectPath);
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(currentProject.path, featureId);
const result = await api.features.getAgentOutput(resolvedProjectPath, featureId);
if (result.success) {
setOutput(result.content || '');
@@ -93,7 +96,7 @@ export function AgentOutputModal({
};
loadOutput();
}, [open, featureId]);
}, [open, featureId, projectPathProp]);
// Listen to auto mode events and update output
useEffect(() => {

View File

@@ -131,7 +131,7 @@ export function DependencyTreeDialog({
: 'bg-muted text-muted-foreground'
)}
>
{dep.status.replace(/_/g, ' ')}
{(dep.status || 'backlog').replace(/_/g, ' ')}
</span>
</div>
</div>
@@ -177,7 +177,7 @@ export function DependencyTreeDialog({
: 'bg-muted text-muted-foreground'
)}
>
{dependent.status.replace(/_/g, ' ')}
{(dependent.status || 'backlog').replace(/_/g, ' ')}
</span>
</div>
</div>

View File

@@ -0,0 +1,736 @@
import { useState, useRef, 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 { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react';
import { toast } from 'sonner';
import type { PipelineConfig, PipelineStep } from '@automaker/types';
import { cn } from '@/lib/utils';
// Color options for pipeline columns
const COLOR_OPTIONS = [
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
];
// Pre-built step templates with well-designed prompts
const STEP_TEMPLATES = [
{
id: 'code-review',
name: 'Code Review',
colorClass: 'bg-blue-500/20',
instructions: `## Code Review
Please perform a thorough code review of the changes made in this feature. Focus on:
### Code Quality
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
- **Maintainability**: Will this code be easy to modify in the future?
- **DRY Principle**: Is there any duplicated code that should be abstracted?
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
### Best Practices
- Follow established patterns and conventions used in the codebase
- Ensure proper error handling is in place
- Check for appropriate logging where needed
- Verify that magic numbers/strings are replaced with named constants
### Performance
- Identify any potential performance bottlenecks
- Check for unnecessary re-renders (React) or redundant computations
- Ensure efficient data structures are used
### Testing
- Verify that new code has appropriate test coverage
- Check that edge cases are handled
### Action Required
After reviewing, make any necessary improvements directly. If you find issues:
1. Fix them immediately if they are straightforward
2. For complex issues, document them clearly with suggested solutions
Provide a brief summary of changes made or issues found.`,
},
{
id: 'security-review',
name: 'Security Review',
colorClass: 'bg-red-500/20',
instructions: `## Security Review
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
### Input Validation & Sanitization
- Verify all user inputs are properly validated and sanitized
- Check for SQL injection vulnerabilities
- Check for XSS (Cross-Site Scripting) vulnerabilities
- Ensure proper encoding of output data
### Authentication & Authorization
- Verify authentication checks are in place where needed
- Ensure authorization logic correctly restricts access
- Check for privilege escalation vulnerabilities
- Verify session management is secure
### Data Protection
- Ensure sensitive data is not logged or exposed
- Check that secrets/credentials are not hardcoded
- Verify proper encryption is used for sensitive data
- Check for secure transmission of data (HTTPS, etc.)
### Common Vulnerabilities (OWASP Top 10)
- Injection flaws
- Broken authentication
- Sensitive data exposure
- XML External Entities (XXE)
- Broken access control
- Security misconfiguration
- Cross-Site Scripting (XSS)
- Insecure deserialization
- Using components with known vulnerabilities
- Insufficient logging & monitoring
### Action Required
1. Fix any security vulnerabilities immediately
2. For complex security issues, document them with severity levels
3. Add security-related comments where appropriate
Provide a security assessment summary with any issues found and fixes applied.`,
},
{
id: 'testing',
name: 'Testing',
colorClass: 'bg-green-500/20',
instructions: `## Testing Step
Please ensure comprehensive test coverage for the changes made in this feature.
### Unit Tests
- Write unit tests for all new functions and methods
- Ensure edge cases are covered
- Test error handling paths
- Aim for high code coverage on new code
### Integration Tests
- Test interactions between components/modules
- Verify API endpoints work correctly
- Test database operations if applicable
### Test Quality
- Tests should be readable and well-documented
- Each test should have a clear purpose
- Use descriptive test names that explain the scenario
- Follow the Arrange-Act-Assert pattern
### Run Tests
After writing tests, run the full test suite and ensure:
1. All new tests pass
2. No existing tests are broken
3. Test coverage meets project standards
Provide a summary of tests added and any issues found during testing.`,
},
{
id: 'documentation',
name: 'Documentation',
colorClass: 'bg-amber-500/20',
instructions: `## Documentation Step
Please ensure all changes are properly documented.
### Code Documentation
- Add/update JSDoc or docstrings for new functions and classes
- Document complex algorithms or business logic
- Add inline comments for non-obvious code
### API Documentation
- Document any new or modified API endpoints
- Include request/response examples
- Document error responses
### README Updates
- Update README if new setup steps are required
- Document any new environment variables
- Update architecture diagrams if applicable
### Changelog
- Document notable changes for the changelog
- Include breaking changes if any
Provide a summary of documentation added or updated.`,
},
{
id: 'optimization',
name: 'Performance Optimization',
colorClass: 'bg-cyan-500/20',
instructions: `## Performance Optimization Step
Review and optimize the performance of the changes made in this feature.
### Code Performance
- Identify and optimize slow algorithms (O(n²) → O(n log n), etc.)
- Remove unnecessary computations or redundant operations
- Optimize loops and iterations
- Use appropriate data structures
### Memory Usage
- Check for memory leaks
- Optimize memory-intensive operations
- Ensure proper cleanup of resources
### Database/API
- Optimize database queries (add indexes, reduce N+1 queries)
- Implement caching where appropriate
- Batch API calls when possible
### Frontend (if applicable)
- Minimize bundle size
- Optimize render performance
- Implement lazy loading where appropriate
- Use memoization for expensive computations
### Action Required
1. Profile the code to identify bottlenecks
2. Apply optimizations
3. Measure improvements
Provide a summary of optimizations applied and performance improvements achieved.`,
},
];
// Helper to get template color class
const getTemplateColorClass = (templateId: string): string => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
return template?.colorClass || COLOR_OPTIONS[0].value;
};
interface PipelineSettingsDialogProps {
open: boolean;
onClose: () => void;
projectPath: string;
pipelineConfig: PipelineConfig | null;
onSave: (config: PipelineConfig) => Promise<void>;
}
interface EditingStep {
id?: string;
name: string;
instructions: string;
colorClass: string;
order: number;
}
export function PipelineSettingsDialog({
open,
onClose,
projectPath,
pipelineConfig,
onSave,
}: PipelineSettingsDialogProps) {
// Filter and validate steps to ensure all required properties exist
const validateSteps = (steps: PipelineStep[] | undefined): PipelineStep[] => {
if (!Array.isArray(steps)) return [];
return steps.filter(
(step): step is PipelineStep =>
step != null &&
typeof step.id === 'string' &&
typeof step.name === 'string' &&
typeof step.instructions === 'string'
);
};
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync steps when dialog opens or pipelineConfig changes
useEffect(() => {
if (open) {
setSteps(validateSteps(pipelineConfig?.steps));
}
}, [open, pipelineConfig]);
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const handleAddStep = () => {
setEditingStep({
name: '',
instructions: '',
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
order: steps.length,
});
};
const handleEditStep = (step: PipelineStep) => {
setEditingStep({
id: step.id,
name: step.name,
instructions: step.instructions,
colorClass: step.colorClass,
order: step.order,
});
};
const handleDeleteStep = (stepId: string) => {
const newSteps = steps.filter((s) => s.id !== stepId);
// Reorder remaining steps
newSteps.forEach((s, index) => {
s.order = index;
});
setSteps(newSteps);
};
const handleMoveStep = (stepId: string, direction: 'up' | 'down') => {
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
if (
(direction === 'up' && stepIndex === 0) ||
(direction === 'down' && stepIndex === sortedSteps.length - 1)
) {
return;
}
const newSteps = [...sortedSteps];
const targetIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
// Swap orders
const temp = newSteps[stepIndex].order;
newSteps[stepIndex].order = newSteps[targetIndex].order;
newSteps[targetIndex].order = temp;
setSteps(newSteps);
};
const handleFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null));
toast.success('Instructions loaded from file');
} catch (error) {
toast.error('Failed to load file');
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSaveStep = () => {
if (!editingStep) return;
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString();
if (editingStep.id) {
// Update existing step
setSteps((prev) =>
prev.map((s) =>
s.id === editingStep.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
updatedAt: now,
}
: s
)
);
} else {
// Add new step
const newStep: PipelineStep = {
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: steps.length,
createdAt: now,
updatedAt: now,
};
setSteps((prev) => [...prev, newStep]);
}
setEditingStep(null);
};
const handleSaveConfig = async () => {
setIsSubmitting(true);
try {
// If the user is currently editing a step and clicks "Save Configuration",
// include that step in the config (common expectation) instead of silently dropping it.
let effectiveSteps = steps;
if (editingStep) {
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString();
if (editingStep.id) {
// Update existing (or add if missing for some reason)
const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id);
if (existingIdx >= 0) {
effectiveSteps = effectiveSteps.map((s) =>
s.id === editingStep.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
updatedAt: now,
}
: s
);
} else {
effectiveSteps = [
...effectiveSteps,
{
id: editingStep.id,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
} else {
// Add new step
effectiveSteps = [
...effectiveSteps,
{
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
// Keep local UI state consistent with what we are saving.
setSteps(effectiveSteps);
setEditingStep(null);
}
const sortedEffectiveSteps = [...effectiveSteps].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);
const config: PipelineConfig = {
version: 1,
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
};
await onSave(config);
toast.success('Pipeline configuration saved');
onClose();
} catch (error) {
toast.error('Failed to save pipeline configuration');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Hidden file input for loading instructions from .md files */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
className="hidden"
onChange={handleFileInputChange}
/>
<DialogHeader>
<DialogTitle>Pipeline Settings</DialogTitle>
<DialogDescription>
Configure custom pipeline steps that run after a feature completes "In Progress". Each
step will automatically prompt the agent with its instructions.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div
key={step.id}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div
className={cn(
'w-3 h-8 rounded',
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
)}
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
<div className="text-xs text-muted-foreground truncate">
{(step.instructions || '').substring(0, 100)}
{(step.instructions || '').length > 100 ? '...' : ''}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
{/* Add Step Button */}
{!editingStep && (
<Button variant="outline" className="w-full" onClick={handleAddStep}>
<Plus className="h-4 w-4 mr-2" />
Add Pipeline Step
</Button>
)}
{/* Edit/Add Step Form */}
{editingStep && (
<div className="border rounded-lg p-4 space-y-4 bg-muted/20">
<div className="flex items-center justify-between">
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingStep(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Template Selector - only show for new steps */}
{!editingStep.id && (
<div className="space-y-2">
<Label>Start from Template</Label>
<Select
onValueChange={(templateId) => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
if (template) {
setEditingStep((prev) =>
prev
? {
...prev,
name: template.name,
instructions: template.instructions,
colorClass: template.colorClass,
}
: null
);
toast.success(`Loaded "${template.name}" template`);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a template (optional)" />
</SelectTrigger>
<SelectContent>
{STEP_TEMPLATES.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
template.colorClass.replace('/20', '')
)}
/>
{template.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select a pre-built template to populate the form, or create your own from
scratch.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="step-name">Step Name</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={editingStep.name}
onChange={(e) =>
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
editingStep.colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() =>
setEditingStep((prev) =>
prev ? { ...prev, colorClass: color.value } : null
)
}
title={color.label}
/>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">Agent Instructions</Label>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleFileUpload}
>
<Upload className="h-3 w-3 mr-1" />
Load from .md file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step..."
value={editingStep.instructions}
onChange={(e) =>
setEditingStep((prev) =>
prev ? { ...prev, instructions: e.target.value } : null
)
}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingStep(null)}>
Cancel
</Button>
<Button onClick={handleSaveStep}>
{editingStep.id ? 'Update Step' : 'Add Step'}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
{isSubmitting
? 'Saving...'
: editingStep
? 'Save Step & Configuration'
: 'Save Configuration'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -23,7 +23,8 @@ export function useBoardColumnFeatures({
}: UseBoardColumnFeaturesProps) {
// Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => {
const map: Record<ColumnId, Feature[]> = {
// Use a more flexible type to support dynamic pipeline statuses
const map: Record<string, Feature[]> = {
backlog: [],
in_progress: [],
waiting_approval: [],
@@ -76,31 +77,56 @@ export function useBoardColumnFeatures({
matchesWorktree = featureBranch === effectiveBranch;
}
// Use the feature's status (fallback to backlog for unknown statuses)
const status = f.status || 'backlog';
// IMPORTANT:
// Historically, we forced "running" features into in_progress so they never disappeared
// during stale reload windows. With pipelines, a feature can legitimately be running while
// its status is `pipeline_*`, so we must respect that status to render it in the right column.
if (isRunning) {
// Only show running tasks if they match the current worktree
if (matchesWorktree) {
if (!matchesWorktree) return;
if (status.startsWith('pipeline_')) {
if (!map[status]) map[status] = [];
map[status].push(f);
return;
}
// If it's running and has a known non-backlog status, keep it in that status.
// Otherwise, fallback to in_progress as the "active work" column.
if (status !== 'backlog' && map[status]) {
map[status].push(f);
} else {
map.in_progress.push(f);
}
} else {
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
const status = f.status as ColumnId;
return;
}
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else {
// Unknown status, default to backlog
if (matchesWorktree) {
map.backlog.push(f);
// Not running: place by status (and worktree filter)
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else if (status.startsWith('pipeline_')) {
// Handle pipeline statuses - initialize array if needed
if (matchesWorktree) {
if (!map[status]) {
map[status] = [];
}
map[status].push(f);
}
} else {
// Unknown status, default to backlog
if (matchesWorktree) {
map.backlog.push(f);
}
}
});
@@ -147,7 +173,7 @@ export function useBoardColumnFeatures({
const getColumnFeatures = useCallback(
(columnId: ColumnId) => {
return columnFeaturesMap[columnId];
return columnFeaturesMap[columnId] || [];
},
[columnFeaturesMap]
);

View File

@@ -203,6 +203,11 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// This ensures the feature card shows the "Approve Plan" button
console.log('[Board] Plan approval required, reloading features...');
loadFeatures();
} else if (event.type === 'pipeline_step_started') {
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
// Reload so the card moves into the correct pipeline column immediately.
console.log('[Board] Pipeline step started, reloading features...');
loadFeatures();
} else if (event.type === 'auto_mode_error') {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log('[Board] Feature error, reloading features...', event.error);

View File

@@ -1,14 +1,15 @@
import { useMemo } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
import { FastForward, Lightbulb, Archive } from 'lucide-react';
import { FastForward, Lightbulb, Archive, Plus, Settings2 } from 'lucide-react';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { COLUMNS, ColumnId } from './constants';
import { getColumnsWithPipeline, type Column, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
interface KanbanBoardProps {
sensors: any;
@@ -49,6 +50,8 @@ interface KanbanBoardProps {
onShowSuggestions: () => void;
suggestionsCount: number;
onArchiveAllVerified: () => void;
pipelineConfig: PipelineConfig | null;
onOpenPipelineSettings?: () => void;
}
export function KanbanBoard({
@@ -82,13 +85,18 @@ export function KanbanBoard({
onShowSuggestions,
suggestionsCount,
onArchiveAllVerified,
pipelineConfig,
onOpenPipelineSettings,
}: KanbanBoardProps) {
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
// Use responsive column widths based on window size
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
const { columnWidth, containerStyle } = useResponsiveKanban(COLUMNS.length);
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
return (
<div className="flex-1 overflow-x-hidden px-5 pb-4 relative" style={backgroundImageStyle}>
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
@@ -96,8 +104,8 @@ export function KanbanBoard({
onDragEnd={onDragEnd}
>
<div className="h-full py-1" style={containerStyle}>
{COLUMNS.map((column) => {
const columnFeatures = getColumnFeatures(column.id);
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
<KanbanColumn
key={column.id}
@@ -156,6 +164,28 @@ export function KanbanBoard({
</HotkeyButton>
)}
</div>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : column.isPipelineStep ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Edit Pipeline Step"
data-testid="edit-pipeline-step-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : undefined
}
>
@@ -210,19 +240,32 @@ export function KanbanBoard({
}}
>
{activeFeature && (
<Card
className="rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform"
style={{ width: `${columnWidth}px` }}
>
<CardHeader className="p-3">
<CardTitle className="text-sm font-medium line-clamp-2">
{activeFeature.description}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
{activeFeature.category}
</CardDescription>
</CardHeader>
</Card>
<div style={{ width: `${columnWidth}px` }}>
<KanbanCard
feature={activeFeature}
isOverlay
onEdit={() => {}}
onDelete={() => {}}
onViewOutput={() => {}}
onVerify={() => {}}
onResume={() => {}}
onForceStop={() => {}}
onManualVerify={() => {}}
onMoveBackToInProgress={() => {}}
onFollowUp={() => {}}
onImplement={() => {}}
onComplete={() => {}}
onViewPlan={() => {}}
onApprovePlan={() => {}}
onSpawnTask={() => {}}
hasContext={featuresWithContext.has(activeFeature.id)}
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
/>
</div>
)}
</DragOverlay>
</DndContext>

View File

@@ -178,6 +178,13 @@ export function ContextView() {
// Ensure context directory exists
await api.mkdir(contextPath);
// Ensure metadata file exists (create empty one if not)
const metadataPath = `${contextPath}/context-metadata.json`;
const metadataExists = await api.exists(metadataPath);
if (!metadataExists) {
await api.writeFile(metadataPath, JSON.stringify({ files: {} }, null, 2));
}
// Load metadata for descriptions
const metadata = await loadMetadata();

View File

@@ -12,12 +12,15 @@ import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-vi
import { ValidationDialog } from './github-issues-view/dialogs';
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
import { useModelOverride } from '@/components/shared';
import type { ValidateIssueOptions } from './github-issues-view/types';
export function GitHubIssuesView() {
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
const [showValidationDialog, setShowValidationDialog] = useState(false);
const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false);
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
useState<ValidateIssueOptions | null>(null);
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } =
useAppStore();
@@ -210,7 +213,10 @@ export function GitHubIssuesView() {
onViewCachedValidation={handleViewCachedValidation}
onOpenInGitHub={handleOpenInGitHub}
onClose={() => setSelectedIssue(null)}
onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)}
onShowRevalidateConfirm={(options) => {
setPendingRevalidateOptions(options);
setShowRevalidateConfirm(true);
}}
formatDate={formatDate}
modelOverride={validationModelOverride}
/>
@@ -228,17 +234,26 @@ export function GitHubIssuesView() {
{/* Revalidate Confirmation Dialog */}
<ConfirmDialog
open={showRevalidateConfirm}
onOpenChange={setShowRevalidateConfirm}
onOpenChange={(open) => {
setShowRevalidateConfirm(open);
if (!open) {
setPendingRevalidateOptions(null);
}
}}
title="Re-validate Issue"
description={`Are you sure you want to re-validate issue #${selectedIssue?.number}? This will run a new AI analysis and replace the existing validation result.`}
icon={RefreshCw}
iconClassName="text-primary"
confirmText="Re-validate"
onConfirm={() => {
if (selectedIssue) {
if (selectedIssue && pendingRevalidateOptions) {
console.log('[GitHubIssuesView] Revalidating with options:', {
commentsCount: pendingRevalidateOptions.comments?.length ?? 0,
linkedPRsCount: pendingRevalidateOptions.linkedPRs?.length ?? 0,
});
handleValidateIssue(selectedIssue, {
...pendingRevalidateOptions,
forceRevalidate: true,
model: validationModelOverride.effectiveModel,
});
}
}}

View File

@@ -0,0 +1,40 @@
import { User } from 'lucide-react';
import { Markdown } from '@/components/ui/markdown';
import type { GitHubComment } from '@/lib/electron';
import { formatDate } from '../utils';
interface CommentItemProps {
comment: GitHubComment;
}
export function CommentItem({ comment }: CommentItemProps) {
return (
<div className="p-3 rounded-lg bg-background border border-border">
{/* Comment Header */}
<div className="flex items-center gap-2 mb-2">
{comment.author.avatarUrl ? (
<img
src={comment.author.avatarUrl}
alt={comment.author.login}
className="h-6 w-6 rounded-full"
/>
) : (
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center">
<User className="h-3 w-3 text-muted-foreground" />
</div>
)}
<span className="text-sm font-medium">{comment.author.login}</span>
<span className="text-xs text-muted-foreground">
commented {formatDate(comment.createdAt)}
</span>
</div>
{/* Comment Body */}
{comment.body ? (
<Markdown className="text-sm">{comment.body}</Markdown>
) : (
<p className="text-sm text-muted-foreground italic">No content</p>
)}
</div>
);
}

View File

@@ -1,3 +1,4 @@
export { IssueRow } from './issue-row';
export { IssueDetailPanel } from './issue-detail-panel';
export { IssuesListHeader } from './issues-list-header';
export { CommentItem } from './comment-item';

View File

@@ -10,13 +10,20 @@ import {
GitPullRequest,
User,
RefreshCw,
MessageSquare,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
import type { IssueDetailPanelProps } from '../types';
import { isValidationStale } from '../utils';
import { ModelOverrideTrigger } from '@/components/shared';
import { useIssueComments } from '../hooks';
import { CommentItem } from './comment-item';
export function IssueDetailPanel({
issue,
@@ -34,6 +41,32 @@ export function IssueDetailPanel({
const cached = cachedValidations.get(issue.number);
const isStale = cached ? isValidationStale(cached.validatedAt) : false;
// Comments state
const [commentsExpanded, setCommentsExpanded] = useState(true);
const [includeCommentsInAnalysis, setIncludeCommentsInAnalysis] = useState(true);
const {
comments,
totalCount,
loading: commentsLoading,
loadingMore,
hasNextPage,
error: commentsError,
loadMore,
} = useIssueComments(issue.number);
// Helper to get validation options with comments and linked PRs
const getValidationOptions = (forceRevalidate = false) => {
return {
forceRevalidate,
comments: includeCommentsInAnalysis && comments.length > 0 ? comments : undefined,
linkedPRs: issue.linkedPRs?.map((pr) => ({
number: pr.number,
title: pr.title,
state: pr.state,
})),
};
};
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
@@ -69,7 +102,7 @@ export function IssueDetailPanel({
<Button
variant="ghost"
size="sm"
onClick={onShowRevalidateConfirm}
onClick={() => onShowRevalidateConfirm(getValidationOptions(true))}
title="Re-validate"
>
<RefreshCw className="h-4 w-4" />
@@ -96,12 +129,7 @@ export function IssueDetailPanel({
<Button
variant="default"
size="sm"
onClick={() =>
onValidateIssue(issue, {
forceRevalidate: true,
model: modelOverride.effectiveModel,
})
}
onClick={() => onValidateIssue(issue, getValidationOptions(true))}
>
<Wand2 className="h-4 w-4 mr-1" />
Re-validate
@@ -123,7 +151,7 @@ export function IssueDetailPanel({
<Button
variant="default"
size="sm"
onClick={() => onValidateIssue(issue, { model: modelOverride.effectiveModel })}
onClick={() => onValidateIssue(issue, getValidationOptions())}
>
<Wand2 className="h-4 w-4 mr-1" />
Validate with AI
@@ -255,6 +283,74 @@ export function IssueDetailPanel({
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
{/* Comments Section */}
<div className="mt-6 p-3 rounded-lg bg-muted/30 border border-border">
<div className="flex items-center justify-between">
<button
className="flex items-center gap-2 text-left"
onClick={() => setCommentsExpanded(!commentsExpanded)}
>
<MessageSquare className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">
Comments {totalCount > 0 && `(${totalCount})`}
</span>
{commentsLoading && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
{commentsExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{comments.length > 0 && (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={includeCommentsInAnalysis}
onCheckedChange={setIncludeCommentsInAnalysis}
/>
Include in AI analysis
</label>
)}
</div>
{commentsExpanded && (
<div className="mt-3">
{commentsError ? (
<p className="text-sm text-red-500">{commentsError}</p>
) : comments.length === 0 && !commentsLoading ? (
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<CommentItem key={comment.id} comment={comment} />
))}
{/* Load More Button */}
{hasNextPage && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={loadMore}
disabled={loadingMore}
>
{loadingMore ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading...
</>
) : (
'Load More Comments'
)}
</Button>
)}
</div>
)}
</div>
)}
</div>
{/* Open in GitHub CTA */}
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-3">

View File

@@ -16,6 +16,9 @@ import {
Lightbulb,
AlertTriangle,
Plus,
GitPullRequest,
Clock,
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type {
@@ -149,6 +152,77 @@ export function ValidationDialog({
</div>
)}
{/* PR Analysis Section - Show AI's analysis of linked PRs */}
{validationResult.prAnalysis && validationResult.prAnalysis.hasOpenPR && (
<div
className={cn(
'p-3 rounded-lg border',
validationResult.prAnalysis.recommendation === 'wait_for_merge'
? 'bg-green-500/10 border-green-500/20'
: validationResult.prAnalysis.recommendation === 'pr_needs_work'
? 'bg-yellow-500/10 border-yellow-500/20'
: 'bg-purple-500/10 border-purple-500/20'
)}
>
<div className="flex items-start gap-2">
{validationResult.prAnalysis.recommendation === 'wait_for_merge' ? (
<Clock className="h-5 w-5 text-green-500 shrink-0 mt-0.5" />
) : validationResult.prAnalysis.recommendation === 'pr_needs_work' ? (
<Wrench className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" />
) : (
<GitPullRequest className="h-5 w-5 text-purple-500 shrink-0 mt-0.5" />
)}
<div className="flex-1">
<span
className={cn(
'text-sm font-medium',
validationResult.prAnalysis.recommendation === 'wait_for_merge'
? 'text-green-500'
: validationResult.prAnalysis.recommendation === 'pr_needs_work'
? 'text-yellow-500'
: 'text-purple-500'
)}
>
{validationResult.prAnalysis.recommendation === 'wait_for_merge'
? 'Fix Ready - Wait for Merge'
: validationResult.prAnalysis.recommendation === 'pr_needs_work'
? 'PR Needs Work'
: 'Work in Progress'}
</span>
{validationResult.prAnalysis.prNumber && (
<p className="text-xs text-muted-foreground mt-0.5">
PR #{validationResult.prAnalysis.prNumber}
{validationResult.prAnalysis.prFixesIssue && ' appears to fix this issue'}
</p>
)}
{validationResult.prAnalysis.prSummary && (
<p className="text-xs text-muted-foreground mt-1">
{validationResult.prAnalysis.prSummary}
</p>
)}
</div>
</div>
</div>
)}
{/* Fallback Work in Progress Badge - Show when there's an open PR but no AI analysis */}
{!validationResult.prAnalysis?.hasOpenPR &&
issue.linkedPRs?.some((pr) => pr.state === 'open' || pr.state === 'OPEN') && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-purple-500/10 border border-purple-500/20">
<GitPullRequest className="h-5 w-5 text-purple-500 shrink-0" />
<div className="flex-1">
<span className="text-sm font-medium text-purple-500">Work in Progress</span>
<p className="text-xs text-muted-foreground mt-0.5">
{issue.linkedPRs
.filter((pr) => pr.state === 'open' || pr.state === 'OPEN')
.map((pr) => `PR #${pr.number}`)
.join(', ')}{' '}
is open for this issue
</p>
</div>
</div>
)}
{/* Reasoning */}
<div className="space-y-2">
<h4 className="text-sm font-medium flex items-center gap-2">
@@ -218,12 +292,14 @@ export function ValidationDialog({
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
</Button>
{validationResult?.verdict === 'valid' && onConvertToTask && (
<Button onClick={handleConvertToTask}>
<Plus className="h-4 w-4 mr-2" />
Convert to Task
</Button>
)}
{validationResult?.verdict === 'valid' &&
onConvertToTask &&
validationResult?.prAnalysis?.recommendation !== 'wait_for_merge' && (
<Button onClick={handleConvertToTask}>
<Plus className="h-4 w-4 mr-2" />
Convert to Task
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,2 +1,3 @@
export { useGithubIssues } from './use-github-issues';
export { useIssueValidation } from './use-issue-validation';
export { useIssueComments } from './use-issue-comments';

View File

@@ -0,0 +1,134 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getElectronAPI, GitHubComment } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
interface UseIssueCommentsResult {
comments: GitHubComment[];
totalCount: number;
loading: boolean;
loadingMore: boolean;
hasNextPage: boolean;
error: string | null;
loadMore: () => void;
refresh: () => void;
}
export function useIssueComments(issueNumber: number | null): UseIssueCommentsResult {
const { currentProject } = useAppStore();
const [comments, setComments] = useState<GitHubComment[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasNextPage, setHasNextPage] = useState(false);
const [endCursor, setEndCursor] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const fetchComments = useCallback(
async (cursor?: string) => {
if (!currentProject?.path || !issueNumber) {
return;
}
const isLoadingMore = !!cursor;
try {
if (isMountedRef.current) {
setError(null);
if (isLoadingMore) {
setLoadingMore(true);
} else {
setLoading(true);
}
}
const api = getElectronAPI();
if (api.github) {
const result = await api.github.getIssueComments(
currentProject.path,
issueNumber,
cursor
);
if (isMountedRef.current) {
if (result.success) {
if (isLoadingMore) {
// Append new comments
setComments((prev) => [...prev, ...(result.comments || [])]);
} else {
// Replace all comments
setComments(result.comments || []);
}
setTotalCount(result.totalCount || 0);
setHasNextPage(result.hasNextPage || false);
setEndCursor(result.endCursor);
} else {
setError(result.error || 'Failed to fetch comments');
}
}
}
} catch (err) {
if (isMountedRef.current) {
console.error('[useIssueComments] Error fetching comments:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch comments');
}
} finally {
if (isMountedRef.current) {
setLoading(false);
setLoadingMore(false);
}
}
},
[currentProject?.path, issueNumber]
);
// Reset and fetch when issue changes
useEffect(() => {
isMountedRef.current = true;
if (issueNumber) {
// Reset state when issue changes
setComments([]);
setTotalCount(0);
setHasNextPage(false);
setEndCursor(undefined);
setError(null);
fetchComments();
} else {
// Clear comments when no issue is selected
setComments([]);
setTotalCount(0);
setHasNextPage(false);
setEndCursor(undefined);
setLoading(false);
setError(null);
}
return () => {
isMountedRef.current = false;
};
}, [issueNumber, fetchComments]);
const loadMore = useCallback(() => {
if (hasNextPage && endCursor && !loadingMore) {
fetchComments(endCursor);
}
}, [hasNextPage, endCursor, loadingMore, fetchComments]);
const refresh = useCallback(() => {
setComments([]);
setEndCursor(undefined);
fetchComments();
}, [fetchComments]);
return {
comments,
totalCount,
loading,
loadingMore,
hasNextPage,
error,
loadMore,
refresh,
};
}

View File

@@ -2,10 +2,12 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import {
getElectronAPI,
GitHubIssue,
GitHubComment,
IssueValidationResult,
IssueValidationEvent,
StoredValidation,
} from '@/lib/electron';
import type { LinkedPRInfo } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { isValidationStale } from '../utils';
@@ -205,8 +207,16 @@ export function useIssueValidation({
}, []);
const handleValidateIssue = useCallback(
async (issue: GitHubIssue, options: { forceRevalidate?: boolean; model?: string } = {}) => {
const { forceRevalidate = false, model } = options;
async (
issue: GitHubIssue,
options: {
forceRevalidate?: boolean;
model?: string;
comments?: GitHubComment[];
linkedPRs?: LinkedPRInfo[];
} = {}
) => {
const { forceRevalidate = false, model, comments, linkedPRs } = options;
if (!currentProject?.path) {
toast.error('No project selected');
@@ -239,14 +249,17 @@ export function useIssueValidation({
try {
const api = getElectronAPI();
if (api.github?.validateIssue) {
const validationInput = {
issueNumber: issue.number,
issueTitle: issue.title,
issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
comments, // Include comments if provided
linkedPRs, // Include linked PRs if provided
};
const result = await api.github.validateIssue(
currentProject.path,
{
issueNumber: issue.number,
issueTitle: issue.title,
issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
},
validationInput,
modelToUse
);

View File

@@ -1,5 +1,5 @@
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
import type { ModelAlias, CursorModelId } from '@automaker/types';
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
import type { ModelAlias, CursorModelId, LinkedPRInfo } from '@automaker/types';
export interface IssueRowProps {
issue: GitHubIssue;
@@ -13,22 +13,26 @@ export interface IssueRowProps {
isValidating?: boolean;
}
/** Options for issue validation */
export interface ValidateIssueOptions {
showDialog?: boolean;
forceRevalidate?: boolean;
/** Include comments in AI analysis */
comments?: GitHubComment[];
/** Linked pull requests */
linkedPRs?: LinkedPRInfo[];
}
export interface IssueDetailPanelProps {
issue: GitHubIssue;
validatingIssues: Set<number>;
cachedValidations: Map<number, StoredValidation>;
onValidateIssue: (
issue: GitHubIssue,
options?: {
showDialog?: boolean;
forceRevalidate?: boolean;
model?: ModelAlias | CursorModelId;
}
) => Promise<void>;
onValidateIssue: (issue: GitHubIssue, options?: ValidateIssueOptions) => Promise<void>;
onViewCachedValidation: (issue: GitHubIssue) => Promise<void>;
onOpenInGitHub: (url: string) => void;
onClose: () => void;
onShowRevalidateConfirm: () => void;
/** Called when user wants to revalidate - receives the validation options including comments/linkedPRs */
onShowRevalidateConfirm: (options: ValidateIssueOptions) => void;
formatDate: (date: string) => string;
/** Model override state */
modelOverride: {

View File

@@ -76,7 +76,10 @@ const priorityConfig = {
};
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
const config = statusConfig[data.status] || statusConfig.backlog;
// Handle pipeline statuses by treating them like in_progress
const status = data.status || 'backlog';
const statusKey = status.startsWith('pipeline_') ? 'in_progress' : status;
const config = statusConfig[statusKey as keyof typeof statusConfig] || statusConfig.backlog;
const StatusIcon = config.icon;
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;

View File

@@ -0,0 +1,110 @@
/**
* Login View - Web mode authentication
*
* Prompts user to enter the API key shown in server console.
* On successful login, sets an HTTP-only session cookie.
*/
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { login } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
export function LoginView() {
const navigate = useNavigate();
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const result = await login(apiKey.trim());
if (result.success) {
// Redirect to home/board on success
navigate({ to: '/' });
} else {
setError(result.error || 'Invalid API key');
}
} catch (err) {
setError('Failed to connect to server');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-8">
{/* Header */}
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<KeyRound className="h-8 w-8 text-primary" />
</div>
<h1 className="mt-6 text-2xl font-bold tracking-tight">Authentication Required</h1>
<p className="mt-2 text-sm text-muted-foreground">
Enter the API key shown in the server console to continue.
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label htmlFor="apiKey" className="text-sm font-medium">
API Key
</label>
<Input
id="apiKey"
type="password"
placeholder="Enter API key..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={isLoading}
autoFocus
className="font-mono"
data-testid="login-api-key-input"
/>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading || !apiKey.trim()}
data-testid="login-submit-button"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Authenticating...
</>
) : (
'Login'
)}
</Button>
</form>
{/* Help Text */}
<div className="rounded-lg border bg-muted/50 p-4 text-sm">
<p className="font-medium">Where to find the API key:</p>
<ol className="mt-2 list-inside list-decimal space-y-1 text-muted-foreground">
<li>Look at the server terminal/console output</li>
<li>Find the box labeled "API Key for Web Mode Authentication"</li>
<li>Copy the UUID displayed there</li>
</ol>
</div>
</div>
</div>
);
}

View File

@@ -1,15 +1,17 @@
import { useState, useEffect, useCallback } from 'react';
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from 'lucide-react';
import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react';
import { getElectronAPI, RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useNavigate } from '@tanstack/react-router';
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
export function RunningAgentsView() {
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
const { setCurrentProject, projects } = useAppStore();
const navigate = useNavigate();
@@ -94,6 +96,10 @@ export function RunningAgentsView() {
[projects, setCurrentProject, navigate]
);
const handleViewLogs = useCallback((agent: RunningAgent) => {
setSelectedAgent(agent);
}, []);
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
@@ -156,15 +162,25 @@ export function RunningAgentsView() {
</div>
{/* Agent info */}
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{agent.featureId}</span>
<span className="font-medium truncate" title={agent.title || agent.featureId}>
{agent.title || agent.featureId}
</span>
{agent.isAutoMode && (
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30">
AUTO
</span>
)}
</div>
{agent.description && (
<p
className="text-sm text-muted-foreground truncate max-w-md"
title={agent.description}
>
{agent.description}
</p>
)}
<button
onClick={() => handleNavigateToProject(agent)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
@@ -177,6 +193,15 @@ export function RunningAgentsView() {
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewLogs(agent)}
className="text-muted-foreground hover:text-foreground"
>
<FileText className="h-3.5 w-3.5 mr-1.5" />
View Logs
</Button>
<Button
variant="ghost"
size="sm"
@@ -199,6 +224,20 @@ export function RunningAgentsView() {
</div>
</div>
)}
{/* Agent Output Modal */}
{selectedAgent && (
<AgentOutputModal
open={true}
onClose={() => setSelectedAgent(null)}
projectPath={selectedAgent.projectPath}
featureDescription={
selectedAgent.description || selectedAgent.title || selectedAgent.featureId
}
featureId={selectedAgent.featureId}
featureStatus="running"
/>
)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useSettingsView } from './settings-view/hooks';
import { NAV_ITEMS } from './settings-view/config/navigation';
@@ -17,6 +18,8 @@ import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/key
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
import { ProviderTabs } from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
import type { Project as ElectronProject } from '@/lib/electron';
@@ -46,6 +49,12 @@ export function SettingsView() {
aiProfiles,
validationModel,
setValidationModel,
autoLoadClaudeMd,
setAutoLoadClaudeMd,
enableSandboxMode,
setEnableSandboxMode,
promptCustomization,
setPromptCustomization,
} = useAppStore();
// Convert electron Project to settings-view Project type
@@ -87,6 +96,15 @@ export function SettingsView() {
case 'providers':
case 'claude': // Backwards compatibility
return <ProviderTabs defaultTab={activeView === 'claude' ? 'claude' : undefined} />;
case 'mcp-servers':
return <MCPServersSection />;
case 'prompts':
return (
<PromptCustomizationSection
promptCustomization={promptCustomization}
onPromptCustomizationChange={setPromptCustomization}
/>
);
case 'ai-enhancement':
return <AIEnhancementSection />;
case 'phase-models':

View File

@@ -1,30 +1,37 @@
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { FileCode } from 'lucide-react';
import { FileCode, Shield } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ClaudeMdSettingsProps {
autoLoadClaudeMd: boolean;
onAutoLoadClaudeMdChange: (enabled: boolean) => void;
enableSandboxMode: boolean;
onEnableSandboxModeChange: (enabled: boolean) => void;
}
/**
* ClaudeMdSettings Component
*
* UI control for the autoLoadClaudeMd setting which enables automatic loading
* of project instructions from .claude/CLAUDE.md files via the Claude Agent SDK.
* UI controls for Claude Agent SDK settings including:
* - Auto-loading of project instructions from .claude/CLAUDE.md files
* - Sandbox mode for isolated bash command execution
*
* Usage:
* ```tsx
* <ClaudeMdSettings
* autoLoadClaudeMd={autoLoadClaudeMd}
* onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
* enableSandboxMode={enableSandboxMode}
* onEnableSandboxModeChange={setEnableSandboxMode}
* />
* ```
*/
export function ClaudeMdSettings({
autoLoadClaudeMd,
onAutoLoadClaudeMdChange,
enableSandboxMode,
onEnableSandboxModeChange,
}: ClaudeMdSettingsProps) {
return (
<div
@@ -76,6 +83,32 @@ export function ClaudeMdSettings({
</p>
</div>
</div>
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-2">
<Checkbox
id="enable-sandbox-mode"
checked={enableSandboxMode}
onCheckedChange={(checked) => onEnableSandboxModeChange(checked === true)}
className="mt-1"
data-testid="enable-sandbox-mode-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="enable-sandbox-mode"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Shield className="w-4 h-4 text-brand-500" />
Enable Sandbox Mode
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Run bash commands in an isolated sandbox environment for additional security.
<span className="block mt-1 text-warning/80">
Note: On some systems, enabling sandbox mode may cause the agent to hang without
responding. If you experience issues, try disabling this option.
</span>
</p>
</div>
</div>
</div>
</div>
);

View File

@@ -10,6 +10,8 @@ import {
Trash2,
Sparkles,
Workflow,
Plug,
MessageSquareText,
} from 'lucide-react';
import type { SettingsViewId } from '../hooks/use-settings-view';
@@ -23,6 +25,8 @@ export interface NavigationItem {
export const NAV_ITEMS: NavigationItem[] = [
{ id: 'api-keys', label: 'API Keys', icon: Key },
{ id: 'providers', label: 'AI Providers', icon: Bot },
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
{ id: 'ai-enhancement', label: 'AI Enhancement', icon: Sparkles },
{ id: 'phase-models', label: 'Phase Models', icon: Workflow },
{ id: 'appearance', label: 'Appearance', icon: Palette },

View File

@@ -4,6 +4,8 @@ export type SettingsViewId =
| 'api-keys'
| 'claude'
| 'providers'
| 'mcp-servers'
| 'prompts'
| 'ai-enhancement'
| 'phase-models'
| 'appearance'

View File

@@ -0,0 +1,4 @@
export { MCPServerHeader } from './mcp-server-header';
export { MCPPermissionSettings } from './mcp-permission-settings';
export { MCPToolsWarning } from './mcp-tools-warning';
export { MCPServerCard } from './mcp-server-card';

View File

@@ -0,0 +1,96 @@
import { ShieldAlert } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { syncSettingsToServer } from '@/hooks/use-settings-migration';
import { cn } from '@/lib/utils';
interface MCPPermissionSettingsProps {
mcpAutoApproveTools: boolean;
mcpUnrestrictedTools: boolean;
onAutoApproveChange: (checked: boolean) => void;
onUnrestrictedChange: (checked: boolean) => void;
}
export function MCPPermissionSettings({
mcpAutoApproveTools,
mcpUnrestrictedTools,
onAutoApproveChange,
onUnrestrictedChange,
}: MCPPermissionSettingsProps) {
const hasAnyEnabled = mcpAutoApproveTools || mcpUnrestrictedTools;
return (
<div className="px-6 py-4 border-b border-border/50 bg-muted/20">
<div className="space-y-4">
<div className="flex items-start gap-3">
<Switch
id="mcp-auto-approve"
checked={mcpAutoApproveTools}
onCheckedChange={async (checked) => {
onAutoApproveChange(checked);
await syncSettingsToServer();
}}
data-testid="mcp-auto-approve-toggle"
className="mt-0.5"
/>
<div className="space-y-1 flex-1">
<Label htmlFor="mcp-auto-approve" className="text-sm font-medium cursor-pointer">
Auto-approve MCP tool calls
</Label>
<p className="text-xs text-muted-foreground">
When enabled, the AI agent can use MCP tools without permission prompts.
</p>
{mcpAutoApproveTools && (
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
<ShieldAlert className="h-3 w-3" />
Bypasses normal permission checks
</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Switch
id="mcp-unrestricted"
checked={mcpUnrestrictedTools}
onCheckedChange={async (checked) => {
onUnrestrictedChange(checked);
await syncSettingsToServer();
}}
data-testid="mcp-unrestricted-toggle"
className="mt-0.5"
/>
<div className="space-y-1 flex-1">
<Label htmlFor="mcp-unrestricted" className="text-sm font-medium cursor-pointer">
Unrestricted tool access
</Label>
<p className="text-xs text-muted-foreground">
When enabled, the AI agent can use any tool, not just the default set.
</p>
{mcpUnrestrictedTools && (
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
<ShieldAlert className="h-3 w-3" />
Agent has full tool access including file writes and bash
</p>
)}
</div>
</div>
{hasAnyEnabled && (
<div
className={cn(
'rounded-md border border-amber-500/30 bg-amber-500/10 p-3 mt-2',
'text-xs text-amber-700 dark:text-amber-400'
)}
>
<p className="font-medium mb-1">Security Note</p>
<p>
These settings reduce security restrictions for MCP tool usage. Only enable if you
trust all configured MCP servers.
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import type { MCPServerConfig } from '@automaker/types';
import type { ServerTestState } from '../types';
import { getServerIcon, getTestStatusIcon, maskSensitiveUrl } from '../utils';
import { MCPToolsList } from '../mcp-tools-list';
interface MCPServerCardProps {
server: MCPServerConfig;
testState?: ServerTestState;
isExpanded: boolean;
onToggleExpanded: () => void;
onTest: () => void;
onToggleEnabled: () => void;
onEditJson: () => void;
onEdit: () => void;
onDelete: () => void;
}
export function MCPServerCard({
server,
testState,
isExpanded,
onToggleExpanded,
onTest,
onToggleEnabled,
onEditJson,
onEdit,
onDelete,
}: MCPServerCardProps) {
const Icon = getServerIcon(server.type);
const hasTools = testState?.tools && testState.tools.length > 0;
return (
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
<div
className={cn(
'rounded-xl border',
server.enabled !== false
? 'border-border/50 bg-accent/20'
: 'border-border/30 bg-muted/30 opacity-60'
)}
data-testid={`mcp-server-${server.id}`}
>
<div className="flex items-center justify-between p-4 gap-2">
<div className="flex items-center gap-3 min-w-0 flex-1 overflow-hidden">
<CollapsibleTrigger asChild>
<button
className={cn(
'flex items-center gap-3 text-left min-w-0 flex-1',
hasTools && 'cursor-pointer hover:opacity-80'
)}
disabled={!hasTools}
>
{hasTools ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
)
) : (
<div className="w-4 shrink-0" />
)}
<div
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
server.enabled !== false ? 'bg-brand-500/20' : 'bg-muted'
)}
>
<Icon className="w-4 h-4 text-brand-500" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm truncate">{server.name}</span>
{testState && getTestStatusIcon(testState.status)}
{testState?.status === 'success' && testState.tools && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
{testState.tools.length} tool{testState.tools.length !== 1 ? 's' : ''}
</span>
)}
</div>
{server.description && (
<div className="text-xs text-muted-foreground truncate">
{server.description}
</div>
)}
<div className="text-xs text-muted-foreground/60 mt-0.5 truncate">
{server.type === 'stdio'
? `${server.command}${server.args?.length ? ' ' + server.args.join(' ') : ''}`
: maskSensitiveUrl(server.url || '')}
</div>
{testState?.status === 'error' && testState.error && (
<div className="text-xs text-destructive mt-1 line-clamp-2 break-words">
{testState.error}
</div>
)}
</div>
</button>
</CollapsibleTrigger>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
<Button
variant="ghost"
size="sm"
onClick={onTest}
disabled={testState?.status === 'testing' || server.enabled === false}
data-testid={`mcp-server-test-${server.id}`}
className="h-8 px-2"
>
{testState?.status === 'testing' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<PlayCircle className="w-4 h-4" />
)}
<span className="ml-1.5 text-xs">Test</span>
</Button>
<Switch
checked={server.enabled !== false}
onCheckedChange={onToggleEnabled}
data-testid={`mcp-server-toggle-${server.id}`}
/>
<Button
variant="ghost"
size="icon"
onClick={onEditJson}
title="Edit JSON"
data-testid={`mcp-server-json-${server.id}`}
>
<Code className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onEdit}
data-testid={`mcp-server-edit-${server.id}`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={onDelete}
data-testid={`mcp-server-delete-${server.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{hasTools && (
<CollapsibleContent>
<div className="px-4 pb-4 pt-0 ml-7 overflow-hidden">
<div className="text-xs font-medium text-muted-foreground mb-2">Available Tools</div>
<MCPToolsList
tools={testState.tools!}
isLoading={testState.status === 'testing'}
error={testState.error}
className="max-w-full"
/>
</div>
</CollapsibleContent>
)}
</div>
</Collapsible>
);
}

View File

@@ -0,0 +1,87 @@
import { Plug, RefreshCw, Download, Code, FileJson, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface MCPServerHeaderProps {
isRefreshing: boolean;
hasServers: boolean;
onRefresh: () => void;
onExport: () => void;
onEditAllJson: () => void;
onImport: () => void;
onAdd: () => void;
}
export function MCPServerHeader({
isRefreshing,
hasServers,
onRefresh,
onExport,
onEditAllJson,
onImport,
onAdd,
}: MCPServerHeaderProps) {
return (
<div className="p-6 border-b border-border/50 bg-linear-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-linear-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Plug className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">MCP Servers</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure Model Context Protocol servers to extend agent capabilities.
</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={onRefresh}
disabled={isRefreshing}
data-testid="refresh-mcp-servers-button"
>
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
</Button>
{hasServers && (
<>
<Button
size="sm"
variant="outline"
onClick={onExport}
data-testid="export-mcp-servers-button"
>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button
size="sm"
variant="outline"
onClick={onEditAllJson}
data-testid="edit-all-json-button"
>
<Code className="w-4 h-4 mr-2" />
Edit JSON
</Button>
</>
)}
<Button
size="sm"
variant="outline"
onClick={onImport}
data-testid="import-mcp-servers-button"
>
<FileJson className="w-4 h-4 mr-2" />
Import JSON
</Button>
<Button size="sm" onClick={onAdd} data-testid="add-mcp-server-button">
<Plus className="w-4 h-4 mr-2" />
Add Server
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { AlertTriangle } from 'lucide-react';
import { MAX_RECOMMENDED_TOOLS } from '../constants';
interface MCPToolsWarningProps {
totalTools: number;
}
export function MCPToolsWarning({ totalTools }: MCPToolsWarningProps) {
return (
<div className="mx-6 mt-4 p-3 rounded-lg border border-yellow-500/50 bg-yellow-500/10">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-500 shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-yellow-600 dark:text-yellow-400">
High tool count detected ({totalTools} tools)
</p>
<p className="text-muted-foreground mt-1">
Having more than {MAX_RECOMMENDED_TOOLS} MCP tools may degrade AI model performance.
Consider disabling unused servers or removing unnecessary tools.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
// Patterns that indicate sensitive values in URLs or config
export const SENSITIVE_PARAM_PATTERNS = [
/api[-_]?key/i,
/api[-_]?token/i,
/auth/i,
/token/i,
/secret/i,
/password/i,
/credential/i,
/bearer/i,
];
// Maximum recommended MCP tools before performance degradation
export const MAX_RECOMMENDED_TOOLS = 80;

View File

@@ -0,0 +1,161 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { MCPServerConfig } from '@automaker/types';
import type { ServerFormData, ServerType } from '../types';
interface AddEditServerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingServer: MCPServerConfig | null;
formData: ServerFormData;
onFormDataChange: (data: ServerFormData) => void;
onSave: () => void;
onCancel: () => void;
}
export function AddEditServerDialog({
open,
onOpenChange,
editingServer,
formData,
onFormDataChange,
onSave,
onCancel,
}: AddEditServerDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="mcp-server-dialog">
<DialogHeader>
<DialogTitle>{editingServer ? 'Edit MCP Server' : 'Add MCP Server'}</DialogTitle>
<DialogDescription>
Configure an MCP server to extend agent capabilities with custom tools.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="server-name">Name</Label>
<Input
id="server-name"
value={formData.name}
onChange={(e) => onFormDataChange({ ...formData, name: e.target.value })}
placeholder="my-mcp-server"
data-testid="mcp-server-name-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-description">Description (optional)</Label>
<Input
id="server-description"
value={formData.description}
onChange={(e) => onFormDataChange({ ...formData, description: e.target.value })}
placeholder="What this server provides..."
data-testid="mcp-server-description-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-type">Transport Type</Label>
<Select
value={formData.type}
onValueChange={(value: ServerType) => onFormDataChange({ ...formData, type: value })}
>
<SelectTrigger id="server-type" data-testid="mcp-server-type-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">Stdio (subprocess)</SelectItem>
<SelectItem value="sse">SSE (Server-Sent Events)</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
</SelectContent>
</Select>
</div>
{formData.type === 'stdio' ? (
<>
<div className="space-y-2">
<Label htmlFor="server-command">Command</Label>
<Input
id="server-command"
value={formData.command}
onChange={(e) => onFormDataChange({ ...formData, command: e.target.value })}
placeholder="npx, node, python, etc."
data-testid="mcp-server-command-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-args">Arguments (space-separated)</Label>
<Input
id="server-args"
value={formData.args}
onChange={(e) => onFormDataChange({ ...formData, args: e.target.value })}
placeholder="-y @modelcontextprotocol/server-filesystem"
data-testid="mcp-server-args-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-env">Environment Variables (JSON, optional)</Label>
<Textarea
id="server-env"
value={formData.env}
onChange={(e) => onFormDataChange({ ...formData, env: e.target.value })}
placeholder={'{\n "API_KEY": "your-key"\n}'}
className="font-mono text-sm h-24"
data-testid="mcp-server-env-input"
/>
</div>
</>
) : (
<>
<div className="space-y-2">
<Label htmlFor="server-url">URL</Label>
<Input
id="server-url"
value={formData.url}
onChange={(e) => onFormDataChange({ ...formData, url: e.target.value })}
placeholder="https://example.com/mcp"
data-testid="mcp-server-url-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-headers">Headers (JSON, optional)</Label>
<Textarea
id="server-headers"
value={formData.headers}
onChange={(e) => onFormDataChange({ ...formData, headers: e.target.value })}
placeholder={
'{\n "x-api-key": "your-api-key",\n "Authorization": "Bearer token"\n}'
}
className="font-mono text-sm h-24"
data-testid="mcp-server-headers-input"
/>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onSave} data-testid="mcp-server-save-button">
{editingServer ? 'Save Changes' : 'Add Server'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,42 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface DeleteServerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
export function DeleteServerDialog({ open, onOpenChange, onConfirm }: DeleteServerDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="mcp-server-delete-dialog">
<DialogHeader>
<DialogTitle>Delete MCP Server</DialogTitle>
<DialogDescription>
Are you sure you want to delete this MCP server? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
data-testid="mcp-server-confirm-delete-button"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,82 @@
import { Code } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface GlobalJsonEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
jsonValue: string;
onJsonValueChange: (value: string) => void;
onSave: () => void;
onCancel: () => void;
}
export function GlobalJsonEditDialog({
open,
onOpenChange,
jsonValue,
onJsonValueChange,
onSave,
onCancel,
}: GlobalJsonEditDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) {
onCancel();
} else {
onOpenChange(open);
}
}}
>
<DialogContent className="max-w-3xl max-h-[90vh]" data-testid="mcp-global-json-edit-dialog">
<DialogHeader>
<DialogTitle>Edit All MCP Servers</DialogTitle>
<DialogDescription>
Edit the full MCP servers configuration. Add, modify, or remove servers directly in
JSON. Servers removed from JSON will be deleted.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Textarea
value={jsonValue}
onChange={(e) => onJsonValueChange(e.target.value)}
placeholder={`{
"mcpServers": {
"server-name": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-name"]
}
}
}`}
className="font-mono text-sm h-[50vh] min-h-[300px]"
data-testid="mcp-global-json-edit-textarea"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button
onClick={onSave}
disabled={!jsonValue.trim()}
data-testid="mcp-global-json-edit-save-button"
>
<Code className="w-4 h-4 mr-2" />
Save All
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,69 @@
import { FileJson } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface ImportJsonDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
importJson: string;
onImportJsonChange: (value: string) => void;
onImport: () => void;
onCancel: () => void;
}
export function ImportJsonDialog({
open,
onOpenChange,
importJson,
onImportJsonChange,
onImport,
onCancel,
}: ImportJsonDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl" data-testid="mcp-import-dialog">
<DialogHeader>
<DialogTitle>Import MCP Servers</DialogTitle>
<DialogDescription>
Paste JSON configuration in Claude Code format. Servers with duplicate names will be
skipped.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Textarea
value={importJson}
onChange={(e) => onImportJsonChange(e.target.value)}
placeholder={`{
"mcpServers": {
"server-name": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-name"],
"type": "stdio"
}
}
}`}
className="font-mono text-sm h-64"
data-testid="mcp-import-textarea"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onImport} disabled={!importJson.trim()} data-testid="mcp-import-button">
<FileJson className="w-4 h-4 mr-2" />
Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,6 @@
export { AddEditServerDialog } from './add-edit-server-dialog';
export { DeleteServerDialog } from './delete-server-dialog';
export { ImportJsonDialog } from './import-json-dialog';
export { JsonEditDialog } from './json-edit-dialog';
export { GlobalJsonEditDialog } from './global-json-edit-dialog';
export { SecurityWarningDialog } from './security-warning-dialog';

View File

@@ -0,0 +1,77 @@
import { Code } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { MCPServerConfig } from '@automaker/types';
interface JsonEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
server: MCPServerConfig | null;
jsonValue: string;
onJsonValueChange: (value: string) => void;
onSave: () => void;
onCancel: () => void;
}
export function JsonEditDialog({
open,
onOpenChange,
server,
jsonValue,
onJsonValueChange,
onSave,
onCancel,
}: JsonEditDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) {
onCancel();
} else {
onOpenChange(open);
}
}}
>
<DialogContent className="max-w-2xl" data-testid="mcp-json-edit-dialog">
<DialogHeader>
<DialogTitle>Edit Server Configuration</DialogTitle>
<DialogDescription>
Edit the raw JSON configuration for "{server?.name}". Changes will be validated before
saving.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Textarea
value={jsonValue}
onChange={(e) => onJsonValueChange(e.target.value)}
placeholder="Server configuration JSON..."
className="font-mono text-sm h-80"
data-testid="mcp-json-edit-textarea"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button
onClick={onSave}
disabled={!jsonValue.trim()}
data-testid="mcp-json-edit-save-button"
>
<Code className="w-4 h-4 mr-2" />
Save JSON
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,107 @@
import { ShieldAlert, Terminal, Globe } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface SecurityWarningDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
serverType: 'stdio' | 'sse' | 'http';
serverName: string;
command?: string;
args?: string[];
url?: string;
/** Number of servers being imported (for import dialog) */
importCount?: number;
}
export function SecurityWarningDialog({
open,
onOpenChange,
onConfirm,
serverType,
serverName,
command,
args,
url,
importCount,
}: SecurityWarningDialogProps) {
const isImport = importCount !== undefined && importCount > 0;
const isStdio = serverType === 'stdio';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg" data-testid="mcp-security-warning-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldAlert className="h-5 w-5 text-amber-500" />
Security Warning
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3 pt-2">
<p className="font-medium text-foreground">
{isImport
? `You are about to import ${importCount} MCP server${importCount > 1 ? 's' : ''}.`
: 'MCP servers can execute code on your machine.'}
</p>
{!isImport && isStdio && command && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Terminal className="h-4 w-4 text-destructive" />
This server will run:
</div>
<code className="mt-1 block break-all text-sm text-muted-foreground">
{command} {args?.join(' ')}
</code>
</div>
)}
{!isImport && !isStdio && url && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Globe className="h-4 w-4 text-amber-500" />
This server will connect to:
</div>
<code className="mt-1 block break-all text-sm text-muted-foreground">{url}</code>
</div>
)}
{isImport && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
<p className="text-sm text-foreground">
Each imported server can execute arbitrary commands or connect to external
services. Review the JSON carefully before importing.
</p>
</div>
)}
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
<li>Only add servers from sources you trust</li>
{isStdio && <li>Stdio servers run with your user privileges</li>}
{!isStdio && <li>HTTP/SSE servers can access network resources</li>}
<li>Review the {isStdio ? 'command' : 'URL'} before confirming</li>
</ul>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={onConfirm} data-testid="mcp-security-confirm-button">
I understand, {isImport ? 'import' : 'add'} server
{isImport && importCount! > 1 ? 's' : ''}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1 @@
export { useMCPServers } from './use-mcp-servers';

View File

@@ -0,0 +1,999 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import type { MCPServerConfig } from '@automaker/types';
import { syncSettingsToServer, loadMCPServersFromServer } from '@/hooks/use-settings-migration';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { ServerFormData, ServerTestState } from '../types';
import { defaultFormData } from '../types';
import { MAX_RECOMMENDED_TOOLS } from '../constants';
import type { ServerType } from '../types';
/** Pending server data waiting for security confirmation */
interface PendingServerData {
type: 'add' | 'import';
serverData?: Omit<MCPServerConfig, 'id'>;
importServers?: Array<Omit<MCPServerConfig, 'id'>>;
serverType: ServerType;
command?: string;
args?: string[];
url?: string;
}
export function useMCPServers() {
const {
mcpServers,
addMCPServer,
updateMCPServer,
removeMCPServer,
mcpAutoApproveTools,
mcpUnrestrictedTools,
setMcpAutoApproveTools,
setMcpUnrestrictedTools,
} = useAppStore();
// State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [editingServer, setEditingServer] = useState<MCPServerConfig | null>(null);
const [formData, setFormData] = useState<ServerFormData>(defaultFormData);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [importJson, setImportJson] = useState('');
const [isRefreshing, setIsRefreshing] = useState(false);
const [serverTestStates, setServerTestStates] = useState<Record<string, ServerTestState>>({});
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
const [jsonEditServer, setJsonEditServer] = useState<MCPServerConfig | null>(null);
const [jsonEditValue, setJsonEditValue] = useState('');
const [isGlobalJsonEditOpen, setIsGlobalJsonEditOpen] = useState(false);
const [globalJsonValue, setGlobalJsonValue] = useState('');
const autoTestedServersRef = useRef<Set<string>>(new Set());
const pendingSyncServerIdsRef = useRef<Set<string>>(new Set());
// Security warning dialog state
const [isSecurityWarningOpen, setIsSecurityWarningOpen] = useState(false);
const [pendingServerData, setPendingServerData] = useState<PendingServerData | null>(null);
// Computed values
const totalToolsCount = useMemo(() => {
let count = 0;
for (const server of mcpServers) {
if (server.enabled !== false) {
const testState = serverTestStates[server.id];
if (testState?.status === 'success' && testState.tools) {
count += testState.tools.length;
}
}
}
return count;
}, [mcpServers, serverTestStates]);
const showToolsWarning = totalToolsCount > MAX_RECOMMENDED_TOOLS;
// Auto-load MCP servers from settings file on mount
useEffect(() => {
loadMCPServersFromServer().catch((error) => {
console.error('Failed to load MCP servers on mount:', error);
});
}, []);
// Test a single server (extracted for reuse)
const testServer = useCallback(async (server: MCPServerConfig, silent = false) => {
setServerTestStates((prev) => ({
...prev,
[server.id]: { status: 'testing' },
}));
try {
const api = getHttpApiClient();
const result = await api.mcp.testServer(server.id);
if (result.success) {
setServerTestStates((prev) => ({
...prev,
[server.id]: {
status: 'success',
tools: result.tools,
connectionTime: result.connectionTime,
},
}));
// Only auto-expand on manual test, not on auto-test (silent)
if (!silent) {
setExpandedServers((prev) => new Set([...prev, server.id]));
toast.success(
`Connected to ${server.name} (${result.tools?.length || 0} tools, ${result.connectionTime}ms)`
);
}
} else {
setServerTestStates((prev) => ({
...prev,
[server.id]: {
status: 'error',
error: result.error,
connectionTime: result.connectionTime,
},
}));
if (!silent) {
toast.error(`Failed to connect: ${result.error}`);
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setServerTestStates((prev) => ({
...prev,
[server.id]: {
status: 'error',
error: errorMessage,
},
}));
if (!silent) {
toast.error(`Test failed: ${errorMessage}`);
}
}
}, []);
// Auto-test all enabled servers on mount (skip servers pending sync)
useEffect(() => {
const enabledServers = mcpServers.filter((s) => s.enabled !== false);
const serversToTest = enabledServers.filter(
(s) => !autoTestedServersRef.current.has(s.id) && !pendingSyncServerIdsRef.current.has(s.id)
);
if (serversToTest.length > 0) {
// Mark all as being tested
serversToTest.forEach((s) => autoTestedServersRef.current.add(s.id));
// Test all servers in parallel (silently - no toast spam)
serversToTest.forEach((server) => {
testServer(server, true);
});
}
}, [mcpServers, testServer]);
const handleRefresh = async () => {
setIsRefreshing(true);
try {
const success = await loadMCPServersFromServer();
if (success) {
toast.success('MCP servers refreshed from settings');
} else {
toast.error('Failed to refresh MCP servers');
}
} catch {
toast.error('Error refreshing MCP servers');
} finally {
setIsRefreshing(false);
}
};
const handleTestServer = (server: MCPServerConfig) => {
testServer(server, false); // false = show toast notifications
};
const toggleServerExpanded = (serverId: string) => {
setExpandedServers((prev) => {
const next = new Set(prev);
if (next.has(serverId)) {
next.delete(serverId);
} else {
next.add(serverId);
}
return next;
});
};
const handleOpenAddDialog = () => {
setFormData(defaultFormData);
setEditingServer(null);
setIsAddDialogOpen(true);
};
const handleOpenEditDialog = (server: MCPServerConfig) => {
setFormData({
name: server.name,
description: server.description || '',
type: server.type || 'stdio',
command: server.command || '',
args: server.args?.join(' ') || '',
url: server.url || '',
headers: server.headers ? JSON.stringify(server.headers, null, 2) : '',
env: server.env ? JSON.stringify(server.env, null, 2) : '',
});
setEditingServer(server);
setIsAddDialogOpen(true);
};
const handleCloseDialog = () => {
setIsAddDialogOpen(false);
setEditingServer(null);
setFormData(defaultFormData);
};
const handleSave = async () => {
if (!formData.name.trim()) {
toast.error('Server name is required');
return;
}
if (formData.type === 'stdio' && !formData.command.trim()) {
toast.error('Command is required for stdio servers');
return;
}
if ((formData.type === 'sse' || formData.type === 'http') && !formData.url.trim()) {
toast.error('URL is required for SSE/HTTP servers');
return;
}
// Parse headers if provided
let parsedHeaders: Record<string, string> | undefined;
if (formData.headers.trim()) {
try {
parsedHeaders = JSON.parse(formData.headers.trim());
if (typeof parsedHeaders !== 'object' || Array.isArray(parsedHeaders)) {
toast.error('Headers must be a JSON object');
return;
}
} catch {
toast.error('Invalid JSON for headers');
return;
}
}
// Parse env if provided
let parsedEnv: Record<string, string> | undefined;
if (formData.env.trim()) {
try {
parsedEnv = JSON.parse(formData.env.trim());
if (typeof parsedEnv !== 'object' || Array.isArray(parsedEnv)) {
toast.error('Environment variables must be a JSON object');
return;
}
} catch {
toast.error('Invalid JSON for environment variables');
return;
}
}
const serverData: Omit<MCPServerConfig, 'id'> = {
name: formData.name.trim(),
description: formData.description.trim() || undefined,
type: formData.type,
enabled: editingServer?.enabled ?? true,
};
if (formData.type === 'stdio') {
serverData.command = formData.command.trim();
if (formData.args.trim()) {
serverData.args = formData.args.trim().split(/\s+/);
}
if (parsedEnv) {
serverData.env = parsedEnv;
}
} else {
serverData.url = formData.url.trim();
if (parsedHeaders) {
serverData.headers = parsedHeaders;
}
}
// If editing an existing server, save directly (user already approved it)
if (editingServer) {
const previousData = { ...editingServer };
updateMCPServer(editingServer.id, serverData);
const syncSuccess = await syncSettingsToServer();
if (!syncSuccess) {
// Rollback local state on sync failure
updateMCPServer(editingServer.id, previousData);
toast.error('Failed to save MCP server to disk');
return;
}
toast.success('MCP server updated');
handleCloseDialog();
return;
}
// For new servers, show security warning first
setPendingServerData({
type: 'add',
serverData,
serverType: formData.type,
command: formData.type === 'stdio' ? formData.command.trim() : undefined,
args:
formData.type === 'stdio' && formData.args.trim()
? formData.args.trim().split(/\s+/)
: undefined,
url: formData.type !== 'stdio' ? formData.url.trim() : undefined,
});
setIsSecurityWarningOpen(true);
};
/** Called when user confirms the security warning for adding a server */
const handleSecurityWarningConfirm = async () => {
if (!pendingServerData) return;
if (pendingServerData.type === 'add' && pendingServerData.serverData) {
// Capture existing IDs before adding to find the new server reliably
const existingIds = new Set(mcpServers.map((s) => s.id));
addMCPServer(pendingServerData.serverData);
// Find the newly added server by comparing IDs
const newServers = useAppStore.getState().mcpServers;
const newServer = newServers.find((s) => !existingIds.has(s.id));
if (newServer) {
pendingSyncServerIdsRef.current.add(newServer.id);
}
const syncSuccess = await syncSettingsToServer();
// Clear pending sync and trigger auto-test after sync
if (newServer) {
pendingSyncServerIdsRef.current.delete(newServer.id);
if (syncSuccess && newServer.enabled !== false) {
testServer(newServer, true);
}
}
if (!syncSuccess) {
toast.error('Failed to save MCP server to disk');
setIsSecurityWarningOpen(false);
setPendingServerData(null);
return;
}
toast.success('MCP server added');
handleCloseDialog();
} else if (pendingServerData.type === 'import' && pendingServerData.importServers) {
// Capture existing IDs before adding to find the new servers reliably
const existingIds = new Set(mcpServers.map((s) => s.id));
for (const serverData of pendingServerData.importServers) {
addMCPServer(serverData);
}
// Find all newly added servers by comparing IDs
const newServers = useAppStore.getState().mcpServers.filter((s) => !existingIds.has(s.id));
newServers.forEach((s) => pendingSyncServerIdsRef.current.add(s.id));
const syncSuccess = await syncSettingsToServer();
// Clear pending sync and trigger auto-test after sync
newServers.forEach((s) => pendingSyncServerIdsRef.current.delete(s.id));
if (syncSuccess) {
for (const server of newServers) {
if (server.enabled !== false) {
testServer(server, true);
}
}
}
if (!syncSuccess) {
toast.error('Failed to save MCP servers to disk');
setIsSecurityWarningOpen(false);
setPendingServerData(null);
return;
}
const count = pendingServerData.importServers.length;
toast.success(`Imported ${count} MCP server${count > 1 ? 's' : ''}`);
setIsImportDialogOpen(false);
setImportJson('');
}
setIsSecurityWarningOpen(false);
setPendingServerData(null);
};
const handleToggleEnabled = async (server: MCPServerConfig) => {
const wasDisabled = server.enabled === false;
const previousEnabled = server.enabled;
updateMCPServer(server.id, { enabled: !server.enabled });
const syncSuccess = await syncSettingsToServer();
if (!syncSuccess) {
// Rollback local state on sync failure
updateMCPServer(server.id, { enabled: previousEnabled });
toast.error('Failed to save settings to disk');
return;
}
toast.success(wasDisabled ? 'Server enabled' : 'Server disabled');
// Auto-test if server was just enabled
if (wasDisabled) {
const updatedServer = useAppStore.getState().mcpServers.find((s) => s.id === server.id);
if (updatedServer) {
testServer(updatedServer, true);
}
}
};
const handleDelete = async (id: string) => {
removeMCPServer(id);
const syncSuccess = await syncSettingsToServer();
setDeleteConfirmId(null);
if (!syncSuccess) {
toast.error('Failed to save settings to disk');
return;
}
toast.success('MCP server removed');
};
/** Helper to parse a server config into importable format */
const parseServerConfig = (
name: string,
serverConfig: Record<string, unknown>
): Omit<MCPServerConfig, 'id'> | null => {
const serverData: Omit<MCPServerConfig, 'id'> = {
name,
type: (serverConfig.type as ServerType) || 'stdio',
enabled: serverConfig.enabled !== false,
};
if (serverConfig.description) {
serverData.description = serverConfig.description as string;
}
if (serverData.type === 'stdio') {
if (!serverConfig.command) {
console.warn(`Skipping ${name}: no command specified`);
return null;
}
const rawCommand = serverConfig.command as string;
// Support both formats:
// 1. Separate command/args: { "command": "npx", "args": ["-y", "package"] }
// 2. Inline args (Claude Desktop format): { "command": "npx -y package" }
if (Array.isArray(serverConfig.args) && serverConfig.args.length > 0) {
serverData.command = rawCommand;
serverData.args = serverConfig.args as string[];
} else if (rawCommand.includes(' ')) {
const parts = rawCommand.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [rawCommand];
serverData.command = parts[0];
if (parts.length > 1) {
serverData.args = parts.slice(1).map((arg) => arg.replace(/^["']|["']$/g, ''));
}
} else {
serverData.command = rawCommand;
}
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
serverData.env = serverConfig.env as Record<string, string>;
}
} else {
if (!serverConfig.url) {
console.warn(`Skipping ${name}: no url specified`);
return null;
}
serverData.url = serverConfig.url as string;
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
serverData.headers = serverConfig.headers as Record<string, string>;
}
}
return serverData;
};
const handleImportJson = async () => {
try {
const parsed = JSON.parse(importJson);
// Support both formats:
// 1. Array format (new): { "mcpServers": [...] } or [...]
// 2. Object format (legacy): { "mcpServers": {...} } or { "name": {...} }
const servers = parsed.mcpServers || parsed;
const serversToImport: Array<Omit<MCPServerConfig, 'id'>> = [];
let skippedCount = 0;
if (Array.isArray(servers)) {
// Array format - each item has name property
for (const serverConfig of servers) {
if (typeof serverConfig !== 'object' || serverConfig === null) continue;
const config = serverConfig as Record<string, unknown>;
const name = config.name as string;
if (!name) {
console.warn('Skipping server: no name specified');
skippedCount++;
continue;
}
// Check if server with this name already exists
if (mcpServers.some((s) => s.name === name)) {
skippedCount++;
continue;
}
const serverData = parseServerConfig(name, config);
if (serverData) {
serversToImport.push(serverData);
} else {
skippedCount++;
}
}
} else if (typeof servers === 'object' && servers !== null) {
// Object format - name is the key
for (const [name, config] of Object.entries(servers)) {
if (typeof config !== 'object' || config === null) continue;
// Check if server with this name already exists
if (mcpServers.some((s) => s.name === name)) {
skippedCount++;
continue;
}
const serverData = parseServerConfig(name, config as Record<string, unknown>);
if (serverData) {
serversToImport.push(serverData);
} else {
skippedCount++;
}
}
} else {
toast.error('Invalid format: expected array or object with server configurations');
return;
}
if (skippedCount > 0) {
toast.info(
`Skipped ${skippedCount} server${skippedCount > 1 ? 's' : ''} (already exist or invalid)`
);
}
if (serversToImport.length === 0) {
toast.warning('No new servers to import');
return;
}
// Show security warning before importing
// Use the first server's type for the warning (most imports are stdio)
const firstServer = serversToImport[0];
setPendingServerData({
type: 'import',
importServers: serversToImport,
serverType: firstServer.type || 'stdio',
command: firstServer.type === 'stdio' ? firstServer.command : undefined,
args: firstServer.type === 'stdio' ? firstServer.args : undefined,
url: firstServer.type !== 'stdio' ? firstServer.url : undefined,
});
setIsSecurityWarningOpen(true);
} catch (error) {
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
}
};
const handleExportJson = () => {
// Export as array format with IDs preserved for full fidelity
const exportData: Array<Record<string, unknown>> = [];
for (const server of mcpServers) {
const serverConfig: Record<string, unknown> = {
id: server.id,
name: server.name,
type: server.type || 'stdio',
enabled: server.enabled ?? true,
};
if (server.description) {
serverConfig.description = server.description;
}
if (server.type === 'stdio' || !server.type) {
serverConfig.command = server.command;
if (server.args?.length) serverConfig.args = server.args;
if (server.env && Object.keys(server.env).length > 0) serverConfig.env = server.env;
} else {
serverConfig.url = server.url;
if (server.headers && Object.keys(server.headers).length > 0)
serverConfig.headers = server.headers;
}
exportData.push(serverConfig);
}
const json = JSON.stringify({ mcpServers: exportData }, null, 2);
navigator.clipboard.writeText(json);
toast.success('Copied to clipboard');
};
const handleOpenJsonEdit = (server: MCPServerConfig) => {
// Build a clean config object for editing (excluding internal fields like id)
const editableConfig: Record<string, unknown> = {
name: server.name,
type: server.type || 'stdio',
};
if (server.description) {
editableConfig.description = server.description;
}
if (server.type === 'stdio' || !server.type) {
if (server.command) editableConfig.command = server.command;
if (server.args?.length) editableConfig.args = server.args;
if (server.env && Object.keys(server.env).length > 0) editableConfig.env = server.env;
} else {
if (server.url) editableConfig.url = server.url;
if (server.headers && Object.keys(server.headers).length > 0) {
editableConfig.headers = server.headers;
}
}
if (server.enabled === false) {
editableConfig.enabled = false;
}
setJsonEditValue(JSON.stringify(editableConfig, null, 2));
setJsonEditServer(server);
};
const handleSaveJsonEdit = async () => {
if (!jsonEditServer) return;
try {
const parsed = JSON.parse(jsonEditValue);
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
toast.error('Config must be a JSON object');
return;
}
// Validate required fields based on type
const serverType = parsed.type || 'stdio';
if (!parsed.name || typeof parsed.name !== 'string') {
toast.error('Name is required');
return;
}
if (serverType === 'stdio') {
if (!parsed.command || typeof parsed.command !== 'string') {
toast.error('Command is required for stdio servers');
return;
}
} else if (serverType === 'sse' || serverType === 'http') {
if (!parsed.url || typeof parsed.url !== 'string') {
toast.error('URL is required for SSE/HTTP servers');
return;
}
}
// Build update object
const updateData: Partial<MCPServerConfig> = {
name: parsed.name,
type: serverType,
description: parsed.description || undefined,
enabled: parsed.enabled !== false,
};
if (serverType === 'stdio') {
updateData.command = parsed.command;
updateData.args = Array.isArray(parsed.args) ? parsed.args : undefined;
updateData.env =
typeof parsed.env === 'object' && !Array.isArray(parsed.env) ? parsed.env : undefined;
// Clear HTTP fields
updateData.url = undefined;
updateData.headers = undefined;
} else {
updateData.url = parsed.url;
updateData.headers =
typeof parsed.headers === 'object' && !Array.isArray(parsed.headers)
? parsed.headers
: undefined;
// Clear stdio fields
updateData.command = undefined;
updateData.args = undefined;
updateData.env = undefined;
}
updateMCPServer(jsonEditServer.id, updateData);
const syncSuccess = await syncSettingsToServer();
if (!syncSuccess) {
toast.error('Failed to save settings to disk');
return;
}
toast.success('Server configuration updated');
setJsonEditServer(null);
setJsonEditValue('');
} catch (error) {
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
}
};
const handleOpenGlobalJsonEdit = () => {
// Build the full mcpServers config as array with IDs preserved
const exportData: Array<Record<string, unknown>> = [];
for (const server of mcpServers) {
const serverConfig: Record<string, unknown> = {
id: server.id,
name: server.name,
type: server.type || 'stdio',
enabled: server.enabled ?? true,
};
if (server.description) {
serverConfig.description = server.description;
}
if (server.type === 'stdio' || !server.type) {
serverConfig.command = server.command;
if (server.args?.length) serverConfig.args = server.args;
if (server.env && Object.keys(server.env).length > 0) serverConfig.env = server.env;
} else {
serverConfig.url = server.url;
if (server.headers && Object.keys(server.headers).length > 0) {
serverConfig.headers = server.headers;
}
}
exportData.push(serverConfig);
}
setGlobalJsonValue(JSON.stringify({ mcpServers: exportData }, null, 2));
setIsGlobalJsonEditOpen(true);
};
/** Helper to save array format (with IDs preserved) */
const handleSaveGlobalJsonArray = async (
serversArray: Array<Record<string, unknown>>
): Promise<boolean> => {
// Validate all servers first
const names = new Set<string>();
for (const serverConfig of serversArray) {
const name = serverConfig.name as string;
if (!name || typeof name !== 'string') {
toast.error('Each server must have a name');
return false;
}
if (names.has(name)) {
toast.error(`Duplicate server name found: "${name}"`);
return false;
}
names.add(name);
const serverType = (serverConfig.type as string) || 'stdio';
if (serverType === 'stdio') {
if (!serverConfig.command || typeof serverConfig.command !== 'string') {
toast.error(`Command is required for "${name}" (stdio)`);
return false;
}
} else if (serverType === 'sse' || serverType === 'http') {
if (!serverConfig.url || typeof serverConfig.url !== 'string') {
toast.error(`URL is required for "${name}" (${serverType})`);
return false;
}
}
}
// Create maps for matching: by ID first, then by name
const existingById = new Map(mcpServers.map((s) => [s.id, s]));
const existingByName = new Map(mcpServers.map((s) => [s.name, s]));
const processedIds = new Set<string>();
// Update or add servers
for (const serverConfig of serversArray) {
const serverType = (serverConfig.type as ServerType) || 'stdio';
const serverId = serverConfig.id as string | undefined;
const serverName = serverConfig.name as string;
const serverData: Omit<MCPServerConfig, 'id'> = {
name: serverName,
type: serverType,
description: (serverConfig.description as string) || undefined,
enabled: serverConfig.enabled !== false,
};
if (serverType === 'stdio') {
serverData.command = serverConfig.command as string;
if (Array.isArray(serverConfig.args)) {
serverData.args = serverConfig.args as string[];
}
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
serverData.env = serverConfig.env as Record<string, string>;
}
} else {
serverData.url = serverConfig.url as string;
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
serverData.headers = serverConfig.headers as Record<string, string>;
}
}
// Match by ID first (allows renaming), then by name (backward compatibility)
const existingServer = serverId ? existingById.get(serverId) : existingByName.get(serverName);
if (existingServer) {
updateMCPServer(existingServer.id, serverData);
processedIds.add(existingServer.id);
} else {
addMCPServer(serverData);
// Get the newly added server ID
const newServers = useAppStore.getState().mcpServers;
const newServer = newServers.find((s) => s.name === serverName);
if (newServer) {
processedIds.add(newServer.id);
}
}
}
// Remove servers that are no longer in the JSON
for (const server of mcpServers) {
if (!processedIds.has(server.id)) {
removeMCPServer(server.id);
}
}
return true;
};
/** Helper to save object format (legacy Claude Desktop format) */
const handleSaveGlobalJsonObject = async (
serversObject: Record<string, Record<string, unknown>>
): Promise<boolean> => {
// Validate all servers first
for (const [name, config] of Object.entries(serversObject)) {
if (typeof config !== 'object' || config === null) {
toast.error(`Invalid config for "${name}"`);
return false;
}
const serverType = (config.type as string) || 'stdio';
if (serverType === 'stdio') {
if (!config.command || typeof config.command !== 'string') {
toast.error(`Command is required for "${name}" (stdio)`);
return false;
}
} else if (serverType === 'sse' || serverType === 'http') {
if (!config.url || typeof config.url !== 'string') {
toast.error(`URL is required for "${name}" (${serverType})`);
return false;
}
}
}
// Create a map of existing servers by name for updating
const existingByName = new Map(mcpServers.map((s) => [s.name, s]));
const processedNames = new Set<string>();
// Update or add servers
for (const [name, config] of Object.entries(serversObject)) {
const serverType = (config.type as ServerType) || 'stdio';
const serverData: Omit<MCPServerConfig, 'id'> = {
name,
type: serverType,
description: (config.description as string) || undefined,
enabled: config.enabled !== false,
};
if (serverType === 'stdio') {
serverData.command = config.command as string;
if (Array.isArray(config.args)) {
serverData.args = config.args as string[];
}
if (typeof config.env === 'object' && config.env !== null) {
serverData.env = config.env as Record<string, string>;
}
} else {
serverData.url = config.url as string;
if (typeof config.headers === 'object' && config.headers !== null) {
serverData.headers = config.headers as Record<string, string>;
}
}
const existing = existingByName.get(name);
if (existing) {
updateMCPServer(existing.id, serverData);
} else {
addMCPServer(serverData);
}
processedNames.add(name);
}
// Remove servers that are no longer in the JSON
for (const server of mcpServers) {
if (!processedNames.has(server.name)) {
removeMCPServer(server.id);
}
}
return true;
};
const handleSaveGlobalJsonEdit = async () => {
try {
const parsed = JSON.parse(globalJsonValue);
// Support both formats:
// 1. Array format (new, with IDs): { mcpServers: [...] } or [...]
// 2. Object format (legacy Claude Desktop): { mcpServers: {...} } or {...}
const servers = parsed.mcpServers || parsed;
let success: boolean;
if (Array.isArray(servers)) {
// Array format - supports ID matching for renames
success = await handleSaveGlobalJsonArray(servers);
} else if (typeof servers === 'object' && servers !== null) {
// Object format - legacy Claude Desktop compatibility
success = await handleSaveGlobalJsonObject(servers);
} else {
toast.error('Invalid format: expected array or object with server configurations');
return;
}
if (!success) {
return;
}
const syncSuccess = await syncSettingsToServer();
if (!syncSuccess) {
toast.error('Failed to save settings to disk');
return;
}
toast.success('MCP servers configuration updated');
setIsGlobalJsonEditOpen(false);
setGlobalJsonValue('');
} catch (error) {
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
}
};
return {
// Store state
mcpServers,
mcpAutoApproveTools,
mcpUnrestrictedTools,
setMcpAutoApproveTools,
setMcpUnrestrictedTools,
// Dialog state
isAddDialogOpen,
setIsAddDialogOpen,
editingServer,
formData,
setFormData,
deleteConfirmId,
setDeleteConfirmId,
isImportDialogOpen,
setIsImportDialogOpen,
importJson,
setImportJson,
jsonEditServer,
setJsonEditServer,
jsonEditValue,
setJsonEditValue,
isGlobalJsonEditOpen,
setIsGlobalJsonEditOpen,
globalJsonValue,
setGlobalJsonValue,
// Security warning dialog state
isSecurityWarningOpen,
setIsSecurityWarningOpen,
pendingServerData,
// UI state
isRefreshing,
serverTestStates,
expandedServers,
// Computed
totalToolsCount,
showToolsWarning,
// Handlers
handleRefresh,
handleTestServer,
toggleServerExpanded,
handleOpenAddDialog,
handleOpenEditDialog,
handleCloseDialog,
handleSave,
handleToggleEnabled,
handleDelete,
handleImportJson,
handleExportJson,
handleOpenJsonEdit,
handleSaveJsonEdit,
handleOpenGlobalJsonEdit,
handleSaveGlobalJsonEdit,
handleSecurityWarningConfirm,
};
}

View File

@@ -0,0 +1,2 @@
export { MCPServersSection } from './mcp-servers-section';
export { MCPToolsList, type MCPToolDisplay } from './mcp-tools-list';

View File

@@ -0,0 +1,213 @@
import { Plug } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useMCPServers } from './hooks';
import {
MCPServerHeader,
MCPPermissionSettings,
MCPToolsWarning,
MCPServerCard,
} from './components';
import {
AddEditServerDialog,
DeleteServerDialog,
ImportJsonDialog,
JsonEditDialog,
GlobalJsonEditDialog,
SecurityWarningDialog,
} from './dialogs';
export function MCPServersSection() {
const {
// Store state
mcpServers,
mcpAutoApproveTools,
mcpUnrestrictedTools,
setMcpAutoApproveTools,
setMcpUnrestrictedTools,
// Dialog state
isAddDialogOpen,
setIsAddDialogOpen,
editingServer,
formData,
setFormData,
deleteConfirmId,
setDeleteConfirmId,
isImportDialogOpen,
setIsImportDialogOpen,
importJson,
setImportJson,
jsonEditServer,
setJsonEditServer,
jsonEditValue,
setJsonEditValue,
isGlobalJsonEditOpen,
setIsGlobalJsonEditOpen,
globalJsonValue,
setGlobalJsonValue,
// Security warning dialog state
isSecurityWarningOpen,
setIsSecurityWarningOpen,
pendingServerData,
// UI state
isRefreshing,
serverTestStates,
expandedServers,
// Computed
totalToolsCount,
showToolsWarning,
// Handlers
handleRefresh,
handleTestServer,
toggleServerExpanded,
handleOpenAddDialog,
handleOpenEditDialog,
handleCloseDialog,
handleSave,
handleToggleEnabled,
handleDelete,
handleImportJson,
handleExportJson,
handleOpenJsonEdit,
handleSaveJsonEdit,
handleOpenGlobalJsonEdit,
handleSaveGlobalJsonEdit,
handleSecurityWarningConfirm,
} = useMCPServers();
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-linear-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<MCPServerHeader
isRefreshing={isRefreshing}
hasServers={mcpServers.length > 0}
onRefresh={handleRefresh}
onExport={handleExportJson}
onEditAllJson={handleOpenGlobalJsonEdit}
onImport={() => setIsImportDialogOpen(true)}
onAdd={handleOpenAddDialog}
/>
{mcpServers.length > 0 && (
<MCPPermissionSettings
mcpAutoApproveTools={mcpAutoApproveTools}
mcpUnrestrictedTools={mcpUnrestrictedTools}
onAutoApproveChange={setMcpAutoApproveTools}
onUnrestrictedChange={setMcpUnrestrictedTools}
/>
)}
{showToolsWarning && <MCPToolsWarning totalTools={totalToolsCount} />}
<div className="p-6">
{mcpServers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Plug className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No MCP servers configured</p>
<p className="text-xs mt-1">Add a server to extend agent capabilities</p>
</div>
) : (
<div className="space-y-3">
{mcpServers.map((server) => (
<MCPServerCard
key={server.id}
server={server}
testState={serverTestStates[server.id]}
isExpanded={expandedServers.has(server.id)}
onToggleExpanded={() => toggleServerExpanded(server.id)}
onTest={() => handleTestServer(server)}
onToggleEnabled={() => handleToggleEnabled(server)}
onEditJson={() => handleOpenJsonEdit(server)}
onEdit={() => handleOpenEditDialog(server)}
onDelete={() => setDeleteConfirmId(server.id)}
/>
))}
</div>
)}
</div>
{/* Dialogs */}
<AddEditServerDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
editingServer={editingServer}
formData={formData}
onFormDataChange={setFormData}
onSave={handleSave}
onCancel={handleCloseDialog}
/>
<DeleteServerDialog
open={!!deleteConfirmId}
onOpenChange={(open) => setDeleteConfirmId(open ? deleteConfirmId : null)}
onConfirm={() => deleteConfirmId && handleDelete(deleteConfirmId)}
/>
<ImportJsonDialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
importJson={importJson}
onImportJsonChange={setImportJson}
onImport={handleImportJson}
onCancel={() => {
setIsImportDialogOpen(false);
setImportJson('');
}}
/>
<JsonEditDialog
open={!!jsonEditServer}
onOpenChange={(open) => {
if (!open) {
setJsonEditServer(null);
setJsonEditValue('');
}
}}
server={jsonEditServer}
jsonValue={jsonEditValue}
onJsonValueChange={setJsonEditValue}
onSave={handleSaveJsonEdit}
onCancel={() => {
setJsonEditServer(null);
setJsonEditValue('');
}}
/>
<GlobalJsonEditDialog
open={isGlobalJsonEditOpen}
onOpenChange={setIsGlobalJsonEditOpen}
jsonValue={globalJsonValue}
onJsonValueChange={setGlobalJsonValue}
onSave={handleSaveGlobalJsonEdit}
onCancel={() => {
setIsGlobalJsonEditOpen(false);
setGlobalJsonValue('');
}}
/>
<SecurityWarningDialog
open={isSecurityWarningOpen}
onOpenChange={setIsSecurityWarningOpen}
onConfirm={handleSecurityWarningConfirm}
serverType={pendingServerData?.serverType || 'stdio'}
serverName={pendingServerData?.serverData?.name || ''}
command={pendingServerData?.command}
args={pendingServerData?.args}
url={pendingServerData?.url}
importCount={
pendingServerData?.type === 'import' ? pendingServerData.importServers?.length : undefined
}
/>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Wrench } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
export interface MCPToolDisplay {
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
enabled: boolean;
}
interface MCPToolsListProps {
tools: MCPToolDisplay[];
isLoading?: boolean;
error?: string;
className?: string;
}
export function MCPToolsList({ tools, isLoading, error, className }: MCPToolsListProps) {
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
const toggleTool = (toolName: string) => {
setExpandedTools((prev) => {
const next = new Set(prev);
if (next.has(toolName)) {
next.delete(toolName);
} else {
next.add(toolName);
}
return next;
});
};
if (isLoading) {
return (
<div className={cn('text-sm text-muted-foreground animate-pulse', className)}>
Loading tools...
</div>
);
}
if (error) {
return <div className={cn('text-sm text-destructive wrap-break-word', className)}>{error}</div>;
}
if (!tools || tools.length === 0) {
return (
<div className={cn('text-sm text-muted-foreground italic', className)}>
No tools available
</div>
);
}
return (
<div className={cn('space-y-1 overflow-hidden', className)}>
{tools.map((tool) => {
const isExpanded = expandedTools.has(tool.name);
const hasSchema = tool.inputSchema && Object.keys(tool.inputSchema).length > 0;
return (
<Collapsible key={tool.name} open={isExpanded} onOpenChange={() => toggleTool(tool.name)}>
<div
className={cn(
'rounded-lg border border-border/30 bg-background/50 overflow-hidden',
'hover:border-border/50 transition-colors'
)}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-start h-auto py-2 px-3 font-normal"
>
<div className="flex items-start gap-2 w-full min-w-0 overflow-hidden">
<div className="flex items-center gap-1.5 shrink-0 mt-0.5">
{hasSchema ? (
isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
)
) : (
<div className="w-3.5" />
)}
<Wrench className="w-3.5 h-3.5 text-brand-500" />
</div>
<div className="flex flex-col items-start text-left min-w-0 overflow-hidden flex-1">
<span className="font-medium text-xs truncate max-w-full">{tool.name}</span>
{tool.description && (
<span className="text-xs text-muted-foreground line-clamp-2 wrap-break-word w-full">
{tool.description}
</span>
)}
</div>
</div>
</Button>
</CollapsibleTrigger>
{hasSchema && (
<CollapsibleContent>
<div className="px-3 pb-2 pt-0 overflow-hidden">
<div className="bg-muted/50 rounded p-2 text-xs font-mono overflow-x-auto max-h-48">
<pre className="whitespace-pre-wrap break-all text-[10px] leading-relaxed">
{JSON.stringify(tool.inputSchema, null, 2)}
</pre>
</div>
</div>
</CollapsibleContent>
)}
</div>
</Collapsible>
);
})}
</div>
);
}

View File

@@ -0,0 +1,32 @@
import type { MCPToolDisplay } from './mcp-tools-list';
export type ServerType = 'stdio' | 'sse' | 'http';
export interface ServerFormData {
name: string;
description: string;
type: ServerType;
command: string;
args: string;
url: string;
headers: string; // JSON string for headers
env: string; // JSON string for env vars
}
export const defaultFormData: ServerFormData = {
name: '',
description: '',
type: 'stdio',
command: '',
args: '',
url: '',
headers: '',
env: '',
};
export interface ServerTestState {
status: 'idle' | 'testing' | 'success' | 'error';
tools?: MCPToolDisplay[];
error?: string;
connectionTime?: number;
}

View File

@@ -0,0 +1,51 @@
import { Terminal, Globe, Loader2, CheckCircle2, XCircle } from 'lucide-react';
import type { ServerType, ServerTestState } from './types';
import { SENSITIVE_PARAM_PATTERNS } from './constants';
/**
* Mask sensitive values in URLs (query params with key-like names)
*/
export function maskSensitiveUrl(url: string): string {
try {
const urlObj = new URL(url);
const params = new URLSearchParams(urlObj.search);
let hasSensitive = false;
for (const [key] of params.entries()) {
if (SENSITIVE_PARAM_PATTERNS.some((pattern) => pattern.test(key))) {
params.set(key, '***');
hasSensitive = true;
}
}
if (hasSensitive) {
urlObj.search = params.toString();
return urlObj.toString();
}
return url;
} catch {
// If URL parsing fails, try simple regex replacement for common patterns
return url.replace(
/([?&])(api[-_]?key|auth|token|secret|password|credential)=([^&]*)/gi,
'$1$2=***'
);
}
}
export function getServerIcon(type: ServerType = 'stdio') {
if (type === 'stdio') return Terminal;
return Globe;
}
export function getTestStatusIcon(status: ServerTestState['status']) {
switch (status) {
case 'testing':
return <Loader2 className="w-4 h-4 animate-spin text-brand-500" />;
case 'success':
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case 'error':
return <XCircle className="w-4 h-4 text-destructive" />;
default:
return null;
}
}

View File

@@ -0,0 +1 @@
export { PromptCustomizationSection } from './prompt-customization-section';

View File

@@ -0,0 +1,440 @@
import { useState } from 'react';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
MessageSquareText,
Bot,
KanbanSquare,
Sparkles,
RotateCcw,
Info,
AlertTriangle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PromptCustomization, CustomPrompt } from '@automaker/types';
import {
DEFAULT_AUTO_MODE_PROMPTS,
DEFAULT_AGENT_PROMPTS,
DEFAULT_BACKLOG_PLAN_PROMPTS,
DEFAULT_ENHANCEMENT_PROMPTS,
} from '@automaker/prompts';
interface PromptCustomizationSectionProps {
promptCustomization?: PromptCustomization;
onPromptCustomizationChange: (customization: PromptCustomization) => void;
}
interface PromptFieldProps {
label: string;
description: string;
defaultValue: string;
customValue?: CustomPrompt;
onCustomValueChange: (value: CustomPrompt | undefined) => void;
critical?: boolean; // Whether this prompt requires strict output format
}
/**
* Calculate dynamic minimum height based on content length
* Ensures long prompts have adequate space
*/
function calculateMinHeight(text: string): string {
const lines = text.split('\n').length;
const estimatedLines = Math.max(lines, Math.ceil(text.length / 80));
// Min 120px, scales up for longer content, max 600px
const minHeight = Math.min(Math.max(120, estimatedLines * 20), 600);
return `${minHeight}px`;
}
/**
* PromptField Component
*
* Shows a prompt with a toggle to switch between default and custom mode.
* - Toggle OFF: Shows default prompt in read-only mode, custom value is preserved but not used
* - Toggle ON: Allows editing, custom value is used instead of default
*
* IMPORTANT: Custom value is ALWAYS preserved, even when toggle is OFF.
* This prevents users from losing their work when temporarily switching to default.
*/
function PromptField({
label,
description,
defaultValue,
customValue,
onCustomValueChange,
critical = false,
}: PromptFieldProps) {
const isEnabled = customValue?.enabled ?? false;
const displayValue = isEnabled ? (customValue?.value ?? defaultValue) : defaultValue;
const minHeight = calculateMinHeight(displayValue);
const handleToggle = (enabled: boolean) => {
// When toggling, preserve the existing custom value if it exists,
// otherwise initialize with the default value.
const value = customValue?.value ?? defaultValue;
onCustomValueChange({ value, enabled });
};
const handleTextChange = (newValue: string) => {
// Only allow editing when enabled
if (isEnabled) {
onCustomValueChange({ value: newValue, enabled: true });
}
};
return (
<div className="space-y-2">
{critical && isEnabled && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs font-medium text-amber-500">Critical Prompt</p>
<p className="text-xs text-muted-foreground mt-1">
This prompt requires a specific output format. Changing it incorrectly may break
functionality. Only modify if you understand the expected structure.
</p>
</div>
</div>
)}
<div className="flex items-center justify-between">
<Label htmlFor={label} className="text-sm font-medium">
{label}
</Label>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{isEnabled ? 'Custom' : 'Default'}</span>
<Switch
checked={isEnabled}
onCheckedChange={handleToggle}
className="data-[state=checked]:bg-brand-500"
/>
</div>
</div>
<Textarea
id={label}
value={displayValue}
onChange={(e) => handleTextChange(e.target.value)}
readOnly={!isEnabled}
style={{ minHeight }}
className={cn(
'font-mono text-xs resize-y',
!isEnabled && 'cursor-not-allowed bg-muted/50 text-muted-foreground'
)}
/>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
);
}
/**
* PromptCustomizationSection Component
*
* Allows users to customize AI prompts for different parts of the application:
* - Auto Mode (feature implementation)
* - Agent Runner (interactive chat)
* - Backlog Plan (Kanban planning)
* - Enhancement (feature description improvement)
*/
export function PromptCustomizationSection({
promptCustomization = {},
onPromptCustomizationChange,
}: PromptCustomizationSectionProps) {
const [activeTab, setActiveTab] = useState('auto-mode');
const updatePrompt = <T extends keyof PromptCustomization>(
category: T,
field: keyof NonNullable<PromptCustomization[T]>,
value: CustomPrompt | undefined
) => {
const updated = {
...promptCustomization,
[category]: {
...promptCustomization[category],
[field]: value,
},
};
onPromptCustomizationChange(updated);
};
const resetToDefaults = (category: keyof PromptCustomization) => {
const updated = {
...promptCustomization,
[category]: {},
};
onPromptCustomizationChange(updated);
};
const resetAllToDefaults = () => {
onPromptCustomizationChange({});
};
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
data-testid="prompt-customization-section"
>
{/* Header */}
<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 justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<MessageSquareText className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Prompt Customization
</h2>
</div>
<Button variant="outline" size="sm" onClick={resetAllToDefaults} className="gap-2">
<RotateCcw className="w-4 h-4" />
Reset All to Defaults
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Customize AI prompts for Auto Mode, Agent Runner, and other features.
</p>
</div>
{/* Info Banner */}
<div className="px-6 pt-6">
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
<Info className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm text-foreground font-medium">How to Customize Prompts</p>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Toggle the switch to enable custom mode and edit the prompt. When disabled, the
default built-in prompt is used. You can use the default as a starting point by
enabling the toggle.
</p>
</div>
</div>
</div>
{/* Tabs */}
<div className="p-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-4 w-full">
<TabsTrigger value="auto-mode" className="gap-2">
<Bot className="w-4 h-4" />
Auto Mode
</TabsTrigger>
<TabsTrigger value="agent" className="gap-2">
<MessageSquareText className="w-4 h-4" />
Agent
</TabsTrigger>
<TabsTrigger value="backlog-plan" className="gap-2">
<KanbanSquare className="w-4 h-4" />
Backlog Plan
</TabsTrigger>
<TabsTrigger value="enhancement" className="gap-2">
<Sparkles className="w-4 h-4" />
Enhancement
</TabsTrigger>
</TabsList>
{/* Auto Mode Tab */}
<TabsContent value="auto-mode" className="space-y-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Auto Mode Prompts</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetToDefaults('autoMode')}
className="gap-2"
>
<RotateCcw className="w-3 h-3" />
Reset Section
</Button>
</div>
{/* Info Banner for Auto Mode */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
<Info className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm text-foreground font-medium">Planning Mode Markers</p>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Planning prompts use special markers like{' '}
<code className="px-1 py-0.5 rounded bg-muted text-xs">[PLAN_GENERATED]</code> and{' '}
<code className="px-1 py-0.5 rounded bg-muted text-xs">[SPEC_GENERATED]</code> to
control the Auto Mode workflow. These markers must be preserved for proper
functionality.
</p>
</div>
</div>
<div className="space-y-4">
<PromptField
label="Planning: Lite Mode"
description="Quick planning outline without approval requirement"
defaultValue={DEFAULT_AUTO_MODE_PROMPTS.planningLite}
customValue={promptCustomization?.autoMode?.planningLite}
onCustomValueChange={(value) => updatePrompt('autoMode', 'planningLite', value)}
critical={true}
/>
<PromptField
label="Planning: Lite with Approval"
description="Planning outline that waits for user approval"
defaultValue={DEFAULT_AUTO_MODE_PROMPTS.planningLiteWithApproval}
customValue={promptCustomization?.autoMode?.planningLiteWithApproval}
onCustomValueChange={(value) =>
updatePrompt('autoMode', 'planningLiteWithApproval', value)
}
critical={true}
/>
<PromptField
label="Planning: Spec Mode"
description="Detailed specification with task breakdown"
defaultValue={DEFAULT_AUTO_MODE_PROMPTS.planningSpec}
customValue={promptCustomization?.autoMode?.planningSpec}
onCustomValueChange={(value) => updatePrompt('autoMode', 'planningSpec', value)}
critical={true}
/>
<PromptField
label="Planning: Full SDD Mode"
description="Comprehensive Software Design Document with phased implementation"
defaultValue={DEFAULT_AUTO_MODE_PROMPTS.planningFull}
customValue={promptCustomization?.autoMode?.planningFull}
onCustomValueChange={(value) => updatePrompt('autoMode', 'planningFull', value)}
critical={true}
/>
</div>
</TabsContent>
{/* Agent Tab */}
<TabsContent value="agent" className="space-y-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Agent Runner Prompts</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetToDefaults('agent')}
className="gap-2"
>
<RotateCcw className="w-3 h-3" />
Reset Section
</Button>
</div>
<div className="space-y-4">
<PromptField
label="System Prompt"
description="Defines the AI's role and behavior in interactive chat sessions"
defaultValue={DEFAULT_AGENT_PROMPTS.systemPrompt}
customValue={promptCustomization?.agent?.systemPrompt}
onCustomValueChange={(value) => updatePrompt('agent', 'systemPrompt', value)}
/>
</div>
</TabsContent>
{/* Backlog Plan Tab */}
<TabsContent value="backlog-plan" className="space-y-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Backlog Planning Prompts</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetToDefaults('backlogPlan')}
className="gap-2"
>
<RotateCcw className="w-3 h-3" />
Reset Section
</Button>
</div>
{/* Critical Warning for Backlog Plan */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-5 h-5 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm text-foreground font-medium">Warning: Critical Prompts</p>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Backlog plan prompts require a strict JSON output format. Modifying these prompts
incorrectly can break the backlog planning feature and potentially corrupt your
feature data. Only customize if you fully understand the expected output
structure.
</p>
</div>
</div>
<div className="space-y-4">
<PromptField
label="System Prompt"
description="Defines how the AI modifies the feature backlog (Plan button on Kanban board)"
defaultValue={DEFAULT_BACKLOG_PLAN_PROMPTS.systemPrompt}
customValue={promptCustomization?.backlogPlan?.systemPrompt}
onCustomValueChange={(value) => updatePrompt('backlogPlan', 'systemPrompt', value)}
critical={true}
/>
</div>
</TabsContent>
{/* Enhancement Tab */}
<TabsContent value="enhancement" className="space-y-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Enhancement Prompts</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetToDefaults('enhancement')}
className="gap-2"
>
<RotateCcw className="w-3 h-3" />
Reset Section
</Button>
</div>
<div className="space-y-4">
<PromptField
label="Improve Mode"
description="Transform vague requests into clear, actionable tasks"
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.improveSystemPrompt}
customValue={promptCustomization?.enhancement?.improveSystemPrompt}
onCustomValueChange={(value) =>
updatePrompt('enhancement', 'improveSystemPrompt', value)
}
/>
<PromptField
label="Technical Mode"
description="Add implementation details and technical specifications"
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.technicalSystemPrompt}
customValue={promptCustomization?.enhancement?.technicalSystemPrompt}
onCustomValueChange={(value) =>
updatePrompt('enhancement', 'technicalSystemPrompt', value)
}
/>
<PromptField
label="Simplify Mode"
description="Make verbose descriptions concise and focused"
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.simplifySystemPrompt}
customValue={promptCustomization?.enhancement?.simplifySystemPrompt}
onCustomValueChange={(value) =>
updatePrompt('enhancement', 'simplifySystemPrompt', value)
}
/>
<PromptField
label="Acceptance Criteria Mode"
description="Add testable acceptance criteria to descriptions"
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.acceptanceSystemPrompt}
customValue={promptCustomization?.enhancement?.acceptanceSystemPrompt}
onCustomValueChange={(value) =>
updatePrompt('enhancement', 'acceptanceSystemPrompt', value)
}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -46,6 +46,8 @@ import {
defaultDropAnimationSideEffects,
} from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { apiFetch, apiGet, apiPost, apiDeleteRaw, getAuthHeaders } from '@/lib/api-fetch';
import { getApiKey } from '@/lib/http-api-client';
interface TerminalStatus {
enabled: boolean;
@@ -240,7 +242,6 @@ export function TerminalView() {
const [dragOverTabId, setDragOverTabId] = useState<string | null>(null);
const lastCreateTimeRef = useRef<number>(0);
const isCreatingRef = useRef<boolean>(false);
const prevProjectPathRef = useRef<string | null>(null);
const restoringProjectPathRef = useRef<string | null>(null);
const [newSessionIds, setNewSessionIds] = useState<Set<string>>(new Set());
const [serverSessionInfo, setServerSessionInfo] = useState<{
@@ -305,16 +306,13 @@ export function TerminalView() {
await Promise.allSettled(
sessionIds.map(async (sessionId) => {
try {
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: 'DELETE',
headers,
});
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
} catch (err) {
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
}
})
);
}, [collectAllSessionIds, terminalState.authToken, serverUrl]);
}, [collectAllSessionIds, terminalState.authToken]);
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
// Helper to check if terminal creation should be debounced
@@ -435,9 +433,10 @@ export function TerminalView() {
try {
setLoading(true);
setError(null);
const response = await fetch(`${serverUrl}/api/terminal/status`);
const data = await response.json();
if (data.success) {
const data = await apiGet<{ success: boolean; data?: TerminalStatus; error?: string }>(
'/api/terminal/status'
);
if (data.success && data.data) {
setStatus(data.data);
if (!data.data.passwordRequired) {
setTerminalUnlocked(true);
@@ -451,7 +450,7 @@ export function TerminalView() {
} finally {
setLoading(false);
}
}, [serverUrl, setTerminalUnlocked]);
}, [setTerminalUnlocked]);
// Fetch server session settings
const fetchServerSettings = useCallback(async () => {
@@ -461,15 +460,17 @@ export function TerminalView() {
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers });
const data = await response.json();
if (data.success) {
const data = await apiGet<{
success: boolean;
data?: { currentSessions: number; maxSessions: number };
}>('/api/terminal/settings', { headers });
if (data.success && data.data) {
setServerSessionInfo({ current: data.data.currentSessions, max: data.data.maxSessions });
}
} catch (err) {
console.error('[Terminal] Failed to fetch server settings:', err);
}
}, [serverUrl, terminalState.isUnlocked, terminalState.authToken]);
}, [terminalState.isUnlocked, terminalState.authToken]);
useEffect(() => {
fetchStatus();
@@ -484,22 +485,20 @@ export function TerminalView() {
const sessionIds = collectAllSessionIds();
if (sessionIds.length === 0) return;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
// Try to use the bulk delete endpoint if available, otherwise delete individually
// Using sendBeacon for reliability during page unload
// Using sync XMLHttpRequest for reliability during page unload (async doesn't complete)
sessionIds.forEach((sessionId) => {
const url = `${serverUrl}/api/terminal/sessions/${sessionId}`;
// sendBeacon doesn't support DELETE method, so we'll use a sync XMLHttpRequest
// which is more reliable during page unload than fetch
try {
const xhr = new XMLHttpRequest();
xhr.open('DELETE', url, false); // synchronous
xhr.withCredentials = true; // Include cookies for session auth
// Add API auth header
const apiKey = getApiKey();
if (apiKey) {
xhr.setRequestHeader('X-API-Key', apiKey);
}
// Add terminal-specific auth
if (terminalState.authToken) {
xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken);
}
@@ -524,11 +523,15 @@ export function TerminalView() {
}, [terminalState.isUnlocked, fetchServerSettings]);
// Handle project switching - save and restore terminal layouts
// Uses terminalState.lastActiveProjectPath (persisted in store) instead of a local ref
// This ensures terminals persist when navigating away from terminal route and back
useEffect(() => {
const currentPath = currentProject?.path || null;
const prevPath = prevProjectPathRef.current;
// Read lastActiveProjectPath directly from store to avoid dependency issues
const prevPath = useAppStore.getState().terminalState.lastActiveProjectPath;
// Skip if no change
// Skip if no change - this now correctly handles route changes within the same project
// because lastActiveProjectPath persists in the store across component unmount/remount
if (currentPath === prevPath) {
return;
}
@@ -538,12 +541,13 @@ export function TerminalView() {
// Save layout for previous project (if there was one and has terminals)
// BUT don't save if we were mid-restore for that project (would save incomplete state)
if (prevPath && terminalState.tabs.length > 0 && restoringProjectPathRef.current !== prevPath) {
const currentTabs = useAppStore.getState().terminalState.tabs;
if (prevPath && currentTabs.length > 0 && restoringProjectPathRef.current !== prevPath) {
saveTerminalLayout(prevPath);
}
// Update the previous project ref
prevProjectPathRef.current = currentPath;
// Update the stored project path
useAppStore.getState().setTerminalLastActiveProjectPath(currentPath);
// Helper to kill sessions and clear state
const killAndClear = async () => {
@@ -589,9 +593,7 @@ export function TerminalView() {
let reconnectedSessions = 0;
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
// Get fresh auth token from store
const authToken = useAppStore.getState().terminalState.authToken;
if (authToken) {
@@ -601,11 +603,9 @@ export function TerminalView() {
// Helper to check if a session still exists on server
const checkSessionExists = async (sessionId: string): Promise<boolean> => {
try {
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: 'GET',
const data = await apiGet<{ success: boolean }>(`/api/terminal/sessions/${sessionId}`, {
headers,
});
const data = await response.json();
return data.success === true;
} catch {
return false;
@@ -615,17 +615,12 @@ export function TerminalView() {
// Helper to create a new terminal session
const createSession = async (): Promise<string | null> => {
try {
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: 'POST',
headers,
body: JSON.stringify({
cwd: currentPath,
cols: 80,
rows: 24,
}),
});
const data = await response.json();
return data.success ? data.data.id : null;
const data = await apiPost<{ success: boolean; data?: { id: string } }>(
'/api/terminal/sessions',
{ cwd: currentPath, cols: 80, rows: 24 },
{ headers }
);
return data.success && data.data ? data.data.id : null;
} catch (err) {
console.error('[Terminal] Failed to create terminal session:', err);
return null;
@@ -797,14 +792,12 @@ export function TerminalView() {
setAuthError(null);
try {
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await response.json();
const data = await apiPost<{ success: boolean; data?: { token: string }; error?: string }>(
'/api/terminal/auth',
{ password }
);
if (data.success) {
if (data.success && data.data) {
setTerminalUnlocked(true, data.data.token);
setPassword('');
} else {
@@ -829,21 +822,14 @@ export function TerminalView() {
}
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: 'POST',
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
cols: 80,
rows: 24,
}),
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
});
const data = await response.json();
@@ -888,21 +874,14 @@ export function TerminalView() {
const tabId = addTerminalTab();
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: 'POST',
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
cols: 80,
rows: 24,
}),
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
});
const data = await response.json();
@@ -955,10 +934,7 @@ export function TerminalView() {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: 'DELETE',
headers,
});
const response = await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
// Always remove from UI - even if server says 404 (session may have already exited)
removeTerminalFromLayout(sessionId);
@@ -1004,10 +980,7 @@ export function TerminalView() {
await Promise.all(
sessionIds.map(async (sessionId) => {
try {
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: 'DELETE',
headers,
});
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
} catch (err) {
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
}

View File

@@ -40,6 +40,7 @@ import {
} from '@/config/terminal-themes';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { getApiKey, getSessionToken } from '@/lib/http-api-client';
// Font size constraints
const MIN_FONT_SIZE = 8;
@@ -485,6 +486,40 @@ export function TerminalPanel({
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
const wsUrl = serverUrl.replace(/^http/, 'ws');
// Fetch a short-lived WebSocket token for secure authentication
const fetchWsToken = useCallback(async (): Promise<string | null> => {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const sessionToken = getSessionToken();
if (sessionToken) {
headers['X-Session-Token'] = sessionToken;
}
const response = await fetch(`${serverUrl}/api/auth/token`, {
headers,
credentials: 'include',
});
if (!response.ok) {
console.warn('[Terminal] Failed to fetch wsToken:', response.status);
return null;
}
const data = await response.json();
if (data.success && data.token) {
return data.token;
}
return null;
} catch (error) {
console.error('[Terminal] Error fetching wsToken:', error);
return null;
}
}, [serverUrl]);
// Draggable - only the drag handle triggers drag
const {
attributes: dragAttributes,
@@ -939,9 +974,24 @@ export function TerminalPanel({
const terminal = xtermRef.current;
if (!terminal) return;
const connect = () => {
// Build WebSocket URL with token
const connect = async () => {
// Build WebSocket URL with auth params
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
// Add API key for Electron mode auth
const apiKey = getApiKey();
if (apiKey) {
url += `&apiKey=${encodeURIComponent(apiKey)}`;
} else {
// In web mode, fetch a short-lived wsToken for secure authentication
const wsToken = await fetchWsToken();
if (wsToken) {
url += `&wsToken=${encodeURIComponent(wsToken)}`;
}
// Cookies are also sent automatically with same-origin WebSocket
}
// Add terminal password token if required
if (authToken) {
url += `&token=${encodeURIComponent(authToken)}`;
}
@@ -1154,7 +1204,7 @@ export function TerminalPanel({
wsRef.current = null;
}
};
}, [sessionId, authToken, wsUrl, isTerminalReady]);
}, [sessionId, authToken, wsUrl, isTerminalReady, fetchWsToken]);
// Handle resize with debouncing
const handleResize = useCallback(() => {