Merge branch 'main' into feat/mass-edit-backlog-features

This commit is contained in:
webdevcody
2026-01-06 14:38:59 -05:00
42 changed files with 2475 additions and 196 deletions

View File

@@ -42,6 +42,9 @@ export function useSpecRegeneration({
}
if (event.type === 'spec_regeneration_complete') {
// Only show toast if we're in active creation flow (not regular regeneration)
const isCreationFlow = creatingSpecProjectPath !== null;
setSpecCreatingForProject(null);
setShowSetupDialog(false);
setProjectOverview('');
@@ -49,9 +52,12 @@ export function useSpecRegeneration({
// Clear onboarding state if we came from onboarding
setNewProjectName('');
setNewProjectPath('');
toast.success('App specification created', {
description: 'Your project is now set up and ready to go!',
});
if (isCreationFlow) {
toast.success('App specification created', {
description: 'Your project is now set up and ready to go!',
});
}
} else if (event.type === 'spec_regeneration_error') {
setSpecCreatingForProject(null);
toast.error('Failed to create specification', {

View File

@@ -22,6 +22,10 @@ interface BoardHeaderProps {
isMounted: boolean;
}
// Shared styles for header control containers
const controlContainerClass =
'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
export function BoardHeader({
projectName,
maxConcurrency,
@@ -60,10 +64,7 @@ export function BoardHeader({
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
{isMounted && (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
data-testid="concurrency-slider-container"
>
<div className={controlContainerClass} data-testid="concurrency-slider-container">
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Agents</span>
<Slider
@@ -86,7 +87,7 @@ export function BoardHeader({
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
</Label>

View File

@@ -168,15 +168,39 @@ function TagFilter({
export function IdeationDashboard({ onGenerateIdeas }: IdeationDashboardProps) {
const currentProject = useAppStore((s) => s.currentProject);
const { generationJobs, removeSuggestionFromJob } = useIdeationStore();
const generationJobs = useIdeationStore((s) => s.generationJobs);
const removeSuggestionFromJob = useIdeationStore((s) => s.removeSuggestionFromJob);
const [addingId, setAddingId] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
// Separate generating/error jobs from ready jobs with suggestions
const activeJobs = generationJobs.filter(
(j) => j.status === 'generating' || j.status === 'error'
// Get jobs for current project only (memoized to prevent unnecessary re-renders)
const projectJobs = useMemo(
() =>
currentProject?.path
? generationJobs.filter((job) => job.projectPath === currentProject.path)
: [],
[generationJobs, currentProject?.path]
);
const readyJobs = generationJobs.filter((j) => j.status === 'ready' && j.suggestions.length > 0);
// Separate jobs by status and compute counts in a single pass
const { activeJobs, readyJobs, generatingCount } = useMemo(() => {
const active: GenerationJob[] = [];
const ready: GenerationJob[] = [];
let generating = 0;
for (const job of projectJobs) {
if (job.status === 'generating') {
active.push(job);
generating++;
} else if (job.status === 'error') {
active.push(job);
} else if (job.status === 'ready' && job.suggestions.length > 0) {
ready.push(job);
}
}
return { activeJobs: active, readyJobs: ready, generatingCount: generating };
}, [projectJobs]);
// Flatten all suggestions with their parent job
const allSuggestions = useMemo(
@@ -203,8 +227,6 @@ export function IdeationDashboard({ onGenerateIdeas }: IdeationDashboardProps) {
return allSuggestions.filter(({ job }) => selectedTags.has(job.prompt.title));
}, [allSuggestions, selectedTags]);
const generatingCount = generationJobs.filter((j) => j.status === 'generating').length;
const handleToggleTag = (tag: string) => {
setSelectedTags((prev) => {
const next = new Set(prev);
@@ -316,6 +338,16 @@ export function IdeationDashboard({ onGenerateIdeas }: IdeationDashboardProps) {
</Card>
)}
{/* Generate More Ideas Button - shown when there are items */}
{!isEmpty && (
<div className="pt-2">
<Button onClick={onGenerateIdeas} variant="outline" className="w-full gap-2">
<Lightbulb className="w-4 h-4" />
Generate More Ideas
</Button>
</div>
)}
{/* Empty State */}
{isEmpty && (
<Card>

View File

@@ -2,7 +2,7 @@
* PromptList - List of prompts for a specific category
*/
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { ArrowLeft, Lightbulb, Loader2, CheckCircle2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
@@ -20,7 +20,10 @@ interface PromptListProps {
export function PromptList({ category, onBack }: PromptListProps) {
const currentProject = useAppStore((s) => s.currentProject);
const { setMode, addGenerationJob, updateJobStatus, generationJobs } = useIdeationStore();
const generationJobs = useIdeationStore((s) => s.generationJobs);
const setMode = useIdeationStore((s) => s.setMode);
const addGenerationJob = useIdeationStore((s) => s.addGenerationJob);
const updateJobStatus = useIdeationStore((s) => s.updateJobStatus);
const [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
const navigate = useNavigate();
@@ -32,9 +35,19 @@ export function PromptList({ category, onBack }: PromptListProps) {
const prompts = getPromptsByCategory(category);
// Get jobs for current project only (memoized to prevent unnecessary re-renders)
const projectJobs = useMemo(
() =>
currentProject?.path
? generationJobs.filter((job) => job.projectPath === currentProject.path)
: [],
[generationJobs, currentProject?.path]
);
// Check which prompts are already generating
const generatingPromptIds = new Set(
generationJobs.filter((j) => j.status === 'generating').map((j) => j.prompt.id)
const generatingPromptIds = useMemo(
() => new Set(projectJobs.filter((j) => j.status === 'generating').map((j) => j.prompt.id)),
[projectJobs]
);
const handleSelectPrompt = async (prompt: IdeationPrompt) => {
@@ -48,7 +61,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
setLoadingPromptId(prompt.id);
// Add a job and navigate to dashboard
const jobId = addGenerationJob(prompt);
const jobId = addGenerationJob(currentProject.path, prompt);
setStartedPrompts((prev) => new Set(prev).add(prompt.id));
// Show toast and navigate to dashboard

View File

@@ -32,6 +32,53 @@ export function useCliStatus() {
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
// Refresh Claude auth status from the server
const refreshAuthStatus = useCallback(async () => {
const api = getElectronAPI();
if (!api?.setup?.getClaudeStatus) return;
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
};
// Map server method names to client method types
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
const validMethods = [
'oauth_token_env',
'oauth_token',
'api_key',
'api_key_env',
'credentials_file',
'cli_authenticated',
'none',
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
: auth.authenticated
? 'api_key'
: 'none'; // Default authenticated to api_key, not none
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid:
auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
};
setClaudeAuthStatus(authStatus);
}
} catch (error) {
logger.error('Failed to refresh Claude auth status:', error);
}
}, [setClaudeAuthStatus]);
// Check CLI status on mount
useEffect(() => {
const checkCliStatus = async () => {
@@ -48,54 +95,13 @@ export function useCliStatus() {
}
// Check Claude auth status (re-fetch on mount to ensure persistence)
if (api?.setup?.getClaudeStatus) {
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
};
// Map server method names to client method types
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
const validMethods = [
'oauth_token_env',
'oauth_token',
'api_key',
'api_key_env',
'credentials_file',
'cli_authenticated',
'none',
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
: auth.authenticated
? 'api_key'
: 'none'; // Default authenticated to api_key, not none
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid:
auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
};
setClaudeAuthStatus(authStatus);
}
} catch (error) {
logger.error('Failed to check Claude auth status:', error);
}
}
await refreshAuthStatus();
};
checkCliStatus();
}, [setClaudeAuthStatus]);
}, [refreshAuthStatus]);
// Refresh Claude CLI status
// Refresh Claude CLI status and auth status
const handleRefreshClaudeCli = useCallback(async () => {
setIsCheckingClaudeCli(true);
try {
@@ -104,12 +110,14 @@ export function useCliStatus() {
const status = await api.checkClaudeCli();
setClaudeCliStatus(status);
}
// Also refresh auth status
await refreshAuthStatus();
} catch (error) {
logger.error('Failed to refresh Claude CLI status:', error);
} finally {
setIsCheckingClaudeCli(false);
}
}, []);
}, [refreshAuthStatus]);
return {
claudeCliStatus,

View File

@@ -8,6 +8,9 @@ interface UseCliStatusOptions {
setAuthStatus: (status: any) => void;
}
// Create logger once outside the hook to prevent infinite re-renders
const logger = createLogger('CliStatus');
export function useCliStatus({
cliType,
statusApi,
@@ -15,7 +18,6 @@ export function useCliStatus({
setAuthStatus,
}: UseCliStatusOptions) {
const [isChecking, setIsChecking] = useState(false);
const logger = createLogger('CliStatus');
const checkStatus = useCallback(async () => {
logger.info(`Starting status check for ${cliType}...`);
@@ -66,7 +68,7 @@ export function useCliStatus({
} finally {
setIsChecking(false);
}
}, [cliType, statusApi, setCliStatus, setAuthStatus, logger]);
}, [cliType, statusApi, setCliStatus, setAuthStatus]);
return { isChecking, checkStatus };
}

View File

@@ -11,7 +11,7 @@ interface ThemeStepProps {
}
export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const { theme, setTheme, setPreviewTheme } = useAppStore();
const { theme, setTheme, setPreviewTheme, currentProject, setProjectTheme } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const handleThemeHover = (themeValue: string) => {
@@ -24,6 +24,11 @@ export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const handleThemeClick = (themeValue: string) => {
setTheme(themeValue as typeof theme);
// Also update the current project's theme if one exists
// This ensures the selected theme is visible since getEffectiveTheme() prioritizes project theme
if (currentProject) {
setProjectTheme(currentProject.id, themeValue as typeof theme);
}
setPreviewTheme(null);
};

View File

@@ -14,7 +14,7 @@ export function SpecView() {
const { currentProject, appSpec } = useAppStore();
// Loading state
const { isLoading, specExists, loadSpec } = useSpecLoading();
const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading();
// Save state
const { isSaving, hasChanges, saveSpec, handleChange, setHasChanges } = useSpecSave();
@@ -82,15 +82,20 @@ export function SpecView() {
);
}
// Empty state - no spec exists
if (!specExists) {
// Empty state - no spec exists or generation is running
// When generation is running, we skip loading the spec to avoid 500 errors,
// so we show the empty state with generation indicator
if (!specExists || isGenerationRunning) {
// If generation is running (from loading hook check), ensure we show the generating UI
const showAsGenerating = isCreating || isGenerationRunning;
return (
<>
<SpecEmptyState
projectPath={currentProject.path}
isCreating={isCreating}
isRegenerating={isRegenerating}
currentPhase={currentPhase}
isCreating={showAsGenerating}
isRegenerating={isRegenerating || isGenerationRunning}
currentPhase={currentPhase || (isGenerationRunning ? 'initialization' : '')}
errorMessage={errorMessage}
onCreateClick={() => setShowCreateDialog(true)}
/>

View File

@@ -9,6 +9,7 @@ export function useSpecLoading() {
const { currentProject, setAppSpec } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const [specExists, setSpecExists] = useState(true);
const [isGenerationRunning, setIsGenerationRunning] = useState(false);
const loadSpec = useCallback(async () => {
if (!currentProject) return;
@@ -16,6 +17,21 @@ export function useSpecLoading() {
setIsLoading(true);
try {
const api = getElectronAPI();
// Check if spec generation is running before trying to load
// This prevents showing "No App Specification Found" during generation
if (api.specRegeneration) {
const status = await api.specRegeneration.status();
if (status.success && status.isRunning) {
logger.debug('Spec generation is running, skipping load');
setIsGenerationRunning(true);
setIsLoading(false);
return;
}
}
// Always reset when generation is not running (handles edge case where api.specRegeneration might not be available)
setIsGenerationRunning(false);
const result = await api.readFile(`${currentProject.path}/.automaker/app_spec.txt`);
if (result.success && result.content) {
@@ -42,6 +58,7 @@ export function useSpecLoading() {
isLoading,
specExists,
setSpecExists,
isGenerationRunning,
loadSpec,
};
}