refactor(ui): migrate settings view to React Query

- Migrate use-cursor-permissions to query and mutation hooks
- Migrate use-cursor-status to React Query
- Migrate use-skills-settings to useUpdateGlobalSettings mutation
- Migrate use-subagents-settings to mutation hooks
- Migrate use-subagents to useDiscoveredAgents query
- Migrate opencode-settings-tab to React Query hooks
- Migrate worktrees-section to query hooks
- Migrate codex/claude usage sections to query hooks
- Remove manual useState for loading/error states

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-15 16:22:04 +01:00
parent c4e0a7cc96
commit 20caa424fc
9 changed files with 289 additions and 690 deletions

View File

@@ -1,12 +1,10 @@
import { useCallback, useEffect, useState } from 'react'; import { useMemo } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useSetupStore } from '@/store/setup-store'; import { useSetupStore } from '@/store/setup-store';
import { useAppStore } from '@/store/app-store'; import { useClaudeUsage } from '@/hooks/queries';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { RefreshCw, AlertCircle } from 'lucide-react'; import { RefreshCw, AlertCircle } from 'lucide-react';
const ERROR_NO_API = 'Claude usage API not available';
const CLAUDE_USAGE_TITLE = 'Claude Usage'; const CLAUDE_USAGE_TITLE = 'Claude Usage';
const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.'; const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.';
const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.'; const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.';
@@ -14,13 +12,10 @@ const CLAUDE_LOGIN_COMMAND = 'claude login';
const CLAUDE_NO_USAGE_MESSAGE = const CLAUDE_NO_USAGE_MESSAGE =
'Usage limits are not available yet. Try refreshing if this persists.'; 'Usage limits are not available yet. Try refreshing if this persists.';
const UPDATED_LABEL = 'Updated'; const UPDATED_LABEL = 'Updated';
const CLAUDE_FETCH_ERROR = 'Failed to fetch usage';
const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage'; const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage';
const WARNING_THRESHOLD = 75; const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50; const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100; const MAX_PERCENTAGE = 100;
const REFRESH_INTERVAL_MS = 60_000;
const STALE_THRESHOLD_MS = 2 * 60_000;
// Using purple/indigo for Claude branding // Using purple/indigo for Claude branding
const USAGE_COLOR_CRITICAL = 'bg-red-500'; const USAGE_COLOR_CRITICAL = 'bg-red-500';
const USAGE_COLOR_WARNING = 'bg-amber-500'; const USAGE_COLOR_WARNING = 'bg-amber-500';
@@ -80,77 +75,31 @@ function UsageCard({
export function ClaudeUsageSection() { export function ClaudeUsageSection() {
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const canFetchUsage = !!claudeAuthStatus?.authenticated; const canFetchUsage = !!claudeAuthStatus?.authenticated;
// Use React Query for data fetching with automatic polling
const {
data: claudeUsage,
isLoading,
isFetching,
error,
dataUpdatedAt,
refetch,
} = useClaudeUsage(canFetchUsage);
// If we have usage data, we can show it even if auth status is unsure // If we have usage data, we can show it even if auth status is unsure
const hasUsage = !!claudeUsage; const hasUsage = !!claudeUsage;
const lastUpdatedLabel = claudeUsageLastUpdated const lastUpdatedLabel = useMemo(() => {
? new Date(claudeUsageLastUpdated).toLocaleString() return dataUpdatedAt ? new Date(dataUpdatedAt).toLocaleString() : null;
: null; }, [dataUpdatedAt]);
const errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
const showAuthWarning = const showAuthWarning =
(!canFetchUsage && !hasUsage && !isLoading) || (!canFetchUsage && !hasUsage && !isLoading) ||
(error && error.includes('Authentication required')); (errorMessage && errorMessage.includes('Authentication required'));
const isStale =
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
const fetchUsage = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api.claude) {
setError(ERROR_NO_API);
return;
}
const result = await api.claude.getUsage();
if ('error' in result) {
// Check for auth errors specifically
if (
result.message?.includes('Authentication required') ||
result.error?.includes('Authentication required')
) {
// We'll show the auth warning UI instead of a generic error
} else {
setError(result.message || result.error);
}
return;
}
setClaudeUsage(result);
} catch (fetchError) {
const message = fetchError instanceof Error ? fetchError.message : CLAUDE_FETCH_ERROR;
setError(message);
} finally {
setIsLoading(false);
}
}, [setClaudeUsage]);
useEffect(() => {
// Initial fetch if authenticated and stale
// Compute staleness inside effect to avoid re-running when Date.now() changes
const isDataStale =
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
if (canFetchUsage && isDataStale) {
void fetchUsage();
}
}, [fetchUsage, canFetchUsage, claudeUsageLastUpdated]);
useEffect(() => {
if (!canFetchUsage) return undefined;
const intervalId = setInterval(() => {
void fetchUsage();
}, REFRESH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [fetchUsage, canFetchUsage]);
return ( return (
<div <div
@@ -172,13 +121,13 @@ export function ClaudeUsageSection() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={fetchUsage} onClick={() => refetch()}
disabled={isLoading} disabled={isFetching}
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50" className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
data-testid="refresh-claude-usage" data-testid="refresh-claude-usage"
title={CLAUDE_REFRESH_LABEL} title={CLAUDE_REFRESH_LABEL}
> >
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} /> <RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
</Button> </Button>
</div> </div>
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p> <p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
@@ -194,10 +143,10 @@ export function ClaudeUsageSection() {
</div> </div>
)} )}
{error && !showAuthWarning && ( {errorMessage && !showAuthWarning && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20"> <div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" /> <AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
<div className="text-sm text-red-400">{error}</div> <div className="text-sm text-red-400">{errorMessage}</div>
</div> </div>
)} )}
@@ -219,7 +168,7 @@ export function ClaudeUsageSection() {
</div> </div>
)} )}
{!hasUsage && !error && !showAuthWarning && !isLoading && ( {!hasUsage && !errorMessage && !showAuthWarning && !isLoading && (
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground"> <div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
{CLAUDE_NO_USAGE_MESSAGE} {CLAUDE_NO_USAGE_MESSAGE}
</div> </div>

View File

@@ -1,19 +1,16 @@
// @ts-nocheck
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { RefreshCw, AlertCircle } from 'lucide-react'; import { RefreshCw, AlertCircle } from 'lucide-react';
import { OpenAIIcon } from '@/components/ui/provider-icon'; import { OpenAIIcon } from '@/components/ui/provider-icon';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { import {
formatCodexPlanType, formatCodexPlanType,
formatCodexResetTime, formatCodexResetTime,
getCodexWindowLabel, getCodexWindowLabel,
} from '@/lib/codex-usage-format'; } from '@/lib/codex-usage-format';
import { useSetupStore } from '@/store/setup-store'; import { useSetupStore } from '@/store/setup-store';
import { useAppStore, type CodexRateLimitWindow } from '@/store/app-store'; import { useCodexUsage } from '@/hooks/queries';
import type { CodexRateLimitWindow } from '@/store/app-store';
const ERROR_NO_API = 'Codex usage API not available';
const CODEX_USAGE_TITLE = 'Codex Usage'; const CODEX_USAGE_TITLE = 'Codex Usage';
const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.'; const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
const CODEX_AUTH_WARNING = 'Authenticate Codex CLI to view usage limits.'; const CODEX_AUTH_WARNING = 'Authenticate Codex CLI to view usage limits.';
@@ -21,14 +18,11 @@ const CODEX_LOGIN_COMMAND = 'codex login';
const CODEX_NO_USAGE_MESSAGE = const CODEX_NO_USAGE_MESSAGE =
'Usage limits are not available yet. Try refreshing if this persists.'; 'Usage limits are not available yet. Try refreshing if this persists.';
const UPDATED_LABEL = 'Updated'; const UPDATED_LABEL = 'Updated';
const CODEX_FETCH_ERROR = 'Failed to fetch usage';
const CODEX_REFRESH_LABEL = 'Refresh Codex usage'; const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
const PLAN_LABEL = 'Plan'; const PLAN_LABEL = 'Plan';
const WARNING_THRESHOLD = 75; const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50; const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100; const MAX_PERCENTAGE = 100;
const REFRESH_INTERVAL_MS = 60_000;
const STALE_THRESHOLD_MS = 2 * 60_000;
const USAGE_COLOR_CRITICAL = 'bg-red-500'; const USAGE_COLOR_CRITICAL = 'bg-red-500';
const USAGE_COLOR_WARNING = 'bg-amber-500'; const USAGE_COLOR_WARNING = 'bg-amber-500';
const USAGE_COLOR_OK = 'bg-emerald-500'; const USAGE_COLOR_OK = 'bg-emerald-500';
@@ -39,11 +33,12 @@ const isRateLimitWindow = (
export function CodexUsageSection() { export function CodexUsageSection() {
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const canFetchUsage = !!codexAuthStatus?.authenticated; const canFetchUsage = !!codexAuthStatus?.authenticated;
// Use React Query for data fetching with automatic polling
const { data: codexUsage, isLoading, isFetching, error, refetch } = useCodexUsage(canFetchUsage);
const rateLimits = codexUsage?.rateLimits ?? null; const rateLimits = codexUsage?.rateLimits ?? null;
const primary = rateLimits?.primary ?? null; const primary = rateLimits?.primary ?? null;
const secondary = rateLimits?.secondary ?? null; const secondary = rateLimits?.secondary ?? null;
@@ -54,46 +49,7 @@ export function CodexUsageSection() {
? new Date(codexUsage.lastUpdated).toLocaleString() ? new Date(codexUsage.lastUpdated).toLocaleString()
: null; : null;
const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading; const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading;
const isStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > STALE_THRESHOLD_MS; const errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
const fetchUsage = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api.codex) {
setError(ERROR_NO_API);
return;
}
const result = await api.codex.getUsage();
if ('error' in result) {
setError(result.message || result.error);
return;
}
setCodexUsage(result);
} catch (fetchError) {
const message = fetchError instanceof Error ? fetchError.message : CODEX_FETCH_ERROR;
setError(message);
} finally {
setIsLoading(false);
}
}, [setCodexUsage]);
useEffect(() => {
if (canFetchUsage && isStale) {
void fetchUsage();
}
}, [fetchUsage, canFetchUsage, isStale]);
useEffect(() => {
if (!canFetchUsage) return undefined;
const intervalId = setInterval(() => {
void fetchUsage();
}, REFRESH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [fetchUsage, canFetchUsage]);
const getUsageColor = (percentage: number) => { const getUsageColor = (percentage: number) => {
if (percentage >= WARNING_THRESHOLD) { if (percentage >= WARNING_THRESHOLD) {
@@ -162,13 +118,13 @@ export function CodexUsageSection() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={fetchUsage} onClick={() => refetch()}
disabled={isLoading} disabled={isFetching}
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50" className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
data-testid="refresh-codex-usage" data-testid="refresh-codex-usage"
title={CODEX_REFRESH_LABEL} title={CODEX_REFRESH_LABEL}
> >
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} /> <RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
</Button> </Button>
</div> </div>
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p> <p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>
@@ -182,10 +138,10 @@ export function CodexUsageSection() {
</div> </div>
</div> </div>
)} )}
{error && ( {errorMessage && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20"> <div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" /> <AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
<div className="text-sm text-red-400">{error}</div> <div className="text-sm text-red-400">{errorMessage}</div>
</div> </div>
)} )}
{hasMetrics && ( {hasMetrics && (
@@ -210,7 +166,7 @@ export function CodexUsageSection() {
</div> </div>
</div> </div>
)} )}
{!hasMetrics && !error && canFetchUsage && !isLoading && ( {!hasMetrics && !errorMessage && canFetchUsage && !isLoading && (
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground"> <div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
{CODEX_NO_USAGE_MESSAGE} {CODEX_NO_USAGE_MESSAGE}
</div> </div>

View File

@@ -1,103 +1,52 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { useCursorPermissionsQuery, type CursorPermissionsData } from '@/hooks/queries';
import { toast } from 'sonner'; import { useApplyCursorProfile, useCopyCursorConfig } from '@/hooks/mutations';
const logger = createLogger('CursorPermissions'); // Re-export for backward compatibility
import { getHttpApiClient } from '@/lib/http-api-client'; export type PermissionsData = CursorPermissionsData;
import type { CursorPermissionProfile } from '@automaker/types';
export interface PermissionsData {
activeProfile: CursorPermissionProfile | null;
effectivePermissions: { allow: string[]; deny: string[] } | null;
hasProjectConfig: boolean;
availableProfiles: Array<{
id: string;
name: string;
description: string;
permissions: { allow: string[]; deny: string[] };
}>;
}
/** /**
* Custom hook for managing Cursor CLI permissions * Custom hook for managing Cursor CLI permissions
* Handles loading permissions data, applying profiles, and copying configs * Handles loading permissions data, applying profiles, and copying configs
*/ */
export function useCursorPermissions(projectPath?: string) { export function useCursorPermissions(projectPath?: string) {
const [permissions, setPermissions] = useState<PermissionsData | null>(null);
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
const [isSavingPermissions, setIsSavingPermissions] = useState(false);
const [copiedConfig, setCopiedConfig] = useState(false); const [copiedConfig, setCopiedConfig] = useState(false);
// Load permissions data // React Query hooks
const loadPermissions = useCallback(async () => { const permissionsQuery = useCursorPermissionsQuery(projectPath);
setIsLoadingPermissions(true); const applyProfileMutation = useApplyCursorProfile(projectPath);
try { const copyConfigMutation = useCopyCursorConfig();
const api = getHttpApiClient();
const result = await api.setup.getCursorPermissions(projectPath);
if (result.success) {
setPermissions({
activeProfile: result.activeProfile || null,
effectivePermissions: result.effectivePermissions || null,
hasProjectConfig: result.hasProjectConfig || false,
availableProfiles: result.availableProfiles || [],
});
}
} catch (error) {
logger.error('Failed to load Cursor permissions:', error);
} finally {
setIsLoadingPermissions(false);
}
}, [projectPath]);
// Apply a permission profile // Apply a permission profile
const applyProfile = useCallback( const applyProfile = useCallback(
async (profileId: 'strict' | 'development', scope: 'global' | 'project') => { (profileId: 'strict' | 'development', scope: 'global' | 'project') => {
setIsSavingPermissions(true); applyProfileMutation.mutate({ profileId, scope });
try {
const api = getHttpApiClient();
const result = await api.setup.applyCursorPermissionProfile(
profileId,
scope,
scope === 'project' ? projectPath : undefined
);
if (result.success) {
toast.success(result.message || `Applied ${profileId} profile`);
await loadPermissions();
} else {
toast.error(result.error || 'Failed to apply profile');
}
} catch (error) {
toast.error('Failed to apply profile');
} finally {
setIsSavingPermissions(false);
}
}, },
[projectPath, loadPermissions] [applyProfileMutation]
); );
// Copy example config to clipboard // Copy example config to clipboard
const copyConfig = useCallback(async (profileId: 'strict' | 'development') => { const copyConfig = useCallback(
try { (profileId: 'strict' | 'development') => {
const api = getHttpApiClient(); copyConfigMutation.mutate(profileId, {
const result = await api.setup.getCursorExampleConfig(profileId); onSuccess: () => {
setCopiedConfig(true);
setTimeout(() => setCopiedConfig(false), 2000);
},
});
},
[copyConfigMutation]
);
if (result.success && result.config) { // Load permissions (refetch)
await navigator.clipboard.writeText(result.config); const loadPermissions = useCallback(() => {
setCopiedConfig(true); permissionsQuery.refetch();
toast.success('Config copied to clipboard'); }, [permissionsQuery]);
setTimeout(() => setCopiedConfig(false), 2000);
}
} catch (error) {
toast.error('Failed to copy config');
}
}, []);
return { return {
permissions, permissions: permissionsQuery.data ?? null,
isLoadingPermissions, isLoadingPermissions: permissionsQuery.isLoading,
isSavingPermissions, isSavingPermissions: applyProfileMutation.isPending,
copiedConfig, copiedConfig,
loadPermissions, loadPermissions,
applyProfile, applyProfile,

View File

@@ -1,9 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useEffect, useMemo, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { useCursorCliStatus } from '@/hooks/queries';
import { toast } from 'sonner';
const logger = createLogger('CursorStatus');
import { getHttpApiClient } from '@/lib/http-api-client';
import { useSetupStore } from '@/store/setup-store'; import { useSetupStore } from '@/store/setup-store';
export interface CursorStatus { export interface CursorStatus {
@@ -15,52 +11,42 @@ export interface CursorStatus {
/** /**
* Custom hook for managing Cursor CLI status * Custom hook for managing Cursor CLI status
* Handles checking CLI installation, authentication, and refresh functionality * Uses React Query for data fetching with automatic caching.
*/ */
export function useCursorStatus() { export function useCursorStatus() {
const { setCursorCliStatus } = useSetupStore(); const { setCursorCliStatus } = useSetupStore();
const { data: result, isLoading, refetch } = useCursorCliStatus();
const [status, setStatus] = useState<CursorStatus | null>(null); // Transform the API result into the local CursorStatus shape
const [isLoading, setIsLoading] = useState(true); const status = useMemo((): CursorStatus | null => {
if (!result) return null;
const loadData = useCallback(async () => { return {
setIsLoading(true); installed: result.installed ?? false,
try { version: result.version ?? undefined,
const api = getHttpApiClient(); authenticated: result.auth?.authenticated ?? false,
const statusResult = await api.setup.getCursorStatus(); method: result.auth?.method,
};
if (statusResult.success) { }, [result]);
const newStatus = {
installed: statusResult.installed ?? false,
version: statusResult.version ?? undefined,
authenticated: statusResult.auth?.authenticated ?? false,
method: statusResult.auth?.method,
};
setStatus(newStatus);
// Also update the global setup store so other components can access the status
setCursorCliStatus({
installed: newStatus.installed,
version: newStatus.version,
auth: newStatus.authenticated
? {
authenticated: true,
method: newStatus.method || 'unknown',
}
: undefined,
});
}
} catch (error) {
logger.error('Failed to load Cursor settings:', error);
toast.error('Failed to load Cursor settings');
} finally {
setIsLoading(false);
}
}, [setCursorCliStatus]);
// Keep the global setup store in sync with query data
useEffect(() => { useEffect(() => {
loadData(); if (status) {
}, [loadData]); setCursorCliStatus({
installed: status.installed,
version: status.version,
auth: status.authenticated
? {
authenticated: true,
method: status.method || 'unknown',
}
: undefined,
});
}
}, [status, setCursorCliStatus]);
const loadData = useCallback(() => {
refetch();
}, [refetch]);
return { return {
status, status,

View File

@@ -5,59 +5,53 @@
* configuring which sources to load Skills from (user/project). * configuring which sources to load Skills from (user/project).
*/ */
import { useState } from 'react'; import { useCallback } from 'react';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron'; import { useUpdateGlobalSettings } from '@/hooks/mutations';
export function useSkillsSettings() { export function useSkillsSettings() {
const enabled = useAppStore((state) => state.enableSkills); const enabled = useAppStore((state) => state.enableSkills);
const sources = useAppStore((state) => state.skillsSources); const sources = useAppStore((state) => state.skillsSources);
const [isLoading, setIsLoading] = useState(false);
const updateEnabled = async (newEnabled: boolean) => { // React Query mutation (disable default toast)
setIsLoading(true); const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false });
try {
const api = getElectronAPI();
if (!api.settings) {
throw new Error('Settings API not available');
}
await api.settings.updateGlobal({ enableSkills: newEnabled });
// Update local store after successful server update
useAppStore.setState({ enableSkills: newEnabled });
toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
} catch (error) {
toast.error('Failed to update skills settings');
console.error(error);
} finally {
setIsLoading(false);
}
};
const updateSources = async (newSources: Array<'user' | 'project'>) => { const updateEnabled = useCallback(
setIsLoading(true); (newEnabled: boolean) => {
try { updateSettingsMutation.mutate(
const api = getElectronAPI(); { enableSkills: newEnabled },
if (!api.settings) { {
throw new Error('Settings API not available'); onSuccess: () => {
} useAppStore.setState({ enableSkills: newEnabled });
await api.settings.updateGlobal({ skillsSources: newSources }); toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
// Update local store after successful server update },
useAppStore.setState({ skillsSources: newSources }); }
toast.success('Skills sources updated'); );
} catch (error) { },
toast.error('Failed to update skills sources'); [updateSettingsMutation]
console.error(error); );
} finally {
setIsLoading(false); const updateSources = useCallback(
} (newSources: Array<'user' | 'project'>) => {
}; updateSettingsMutation.mutate(
{ skillsSources: newSources },
{
onSuccess: () => {
useAppStore.setState({ skillsSources: newSources });
toast.success('Skills sources updated');
},
}
);
},
[updateSettingsMutation]
);
return { return {
enabled, enabled,
sources, sources,
updateEnabled, updateEnabled,
updateSources, updateSources,
isLoading, isLoading: updateSettingsMutation.isPending,
}; };
} }

View File

@@ -5,59 +5,53 @@
* configuring which sources to load Subagents from (user/project). * configuring which sources to load Subagents from (user/project).
*/ */
import { useState } from 'react'; import { useCallback } from 'react';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron'; import { useUpdateGlobalSettings } from '@/hooks/mutations';
export function useSubagentsSettings() { export function useSubagentsSettings() {
const enabled = useAppStore((state) => state.enableSubagents); const enabled = useAppStore((state) => state.enableSubagents);
const sources = useAppStore((state) => state.subagentsSources); const sources = useAppStore((state) => state.subagentsSources);
const [isLoading, setIsLoading] = useState(false);
const updateEnabled = async (newEnabled: boolean) => { // React Query mutation (disable default toast)
setIsLoading(true); const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false });
try {
const api = getElectronAPI();
if (!api.settings) {
throw new Error('Settings API not available');
}
await api.settings.updateGlobal({ enableSubagents: newEnabled });
// Update local store after successful server update
useAppStore.setState({ enableSubagents: newEnabled });
toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
} catch (error) {
toast.error('Failed to update subagents settings');
console.error(error);
} finally {
setIsLoading(false);
}
};
const updateSources = async (newSources: Array<'user' | 'project'>) => { const updateEnabled = useCallback(
setIsLoading(true); (newEnabled: boolean) => {
try { updateSettingsMutation.mutate(
const api = getElectronAPI(); { enableSubagents: newEnabled },
if (!api.settings) { {
throw new Error('Settings API not available'); onSuccess: () => {
} useAppStore.setState({ enableSubagents: newEnabled });
await api.settings.updateGlobal({ subagentsSources: newSources }); toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
// Update local store after successful server update },
useAppStore.setState({ subagentsSources: newSources }); }
toast.success('Subagents sources updated'); );
} catch (error) { },
toast.error('Failed to update subagents sources'); [updateSettingsMutation]
console.error(error); );
} finally {
setIsLoading(false); const updateSources = useCallback(
} (newSources: Array<'user' | 'project'>) => {
}; updateSettingsMutation.mutate(
{ subagentsSources: newSources },
{
onSuccess: () => {
useAppStore.setState({ subagentsSources: newSources });
toast.success('Subagents sources updated');
},
}
);
},
[updateSettingsMutation]
);
return { return {
enabled, enabled,
sources, sources,
updateEnabled, updateEnabled,
updateSources, updateSources,
isLoading, isLoading: updateSettingsMutation.isPending,
}; };
} }

View File

@@ -9,10 +9,12 @@
* Agent definitions in settings JSON are used server-side only. * Agent definitions in settings JSON are used server-side only.
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useMemo, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import type { AgentDefinition } from '@automaker/types'; import type { AgentDefinition } from '@automaker/types';
import { getElectronAPI } from '@/lib/electron'; import { useDiscoveredAgents } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
export type SubagentScope = 'global' | 'project'; export type SubagentScope = 'global' | 'project';
export type SubagentType = 'filesystem'; export type SubagentType = 'filesystem';
@@ -35,51 +37,40 @@ interface FilesystemAgent {
} }
export function useSubagents() { export function useSubagents() {
const queryClient = useQueryClient();
const currentProject = useAppStore((state) => state.currentProject); const currentProject = useAppStore((state) => state.currentProject);
const [isLoading, setIsLoading] = useState(false);
const [subagentsWithScope, setSubagentsWithScope] = useState<SubagentWithScope[]>([]);
// Fetch filesystem agents // Use React Query hook for fetching agents
const fetchFilesystemAgents = useCallback(async () => { const {
setIsLoading(true); data: agents = [],
try { isLoading,
const api = getElectronAPI(); refetch,
if (!api.settings) { } = useDiscoveredAgents(currentProject?.path, ['user', 'project']);
console.warn('Settings API not available');
return;
}
const data = await api.settings.discoverAgents(currentProject?.path, ['user', 'project']);
if (data.success && data.agents) { // Transform agents to SubagentWithScope format
// Transform filesystem agents to SubagentWithScope format const subagentsWithScope = useMemo((): SubagentWithScope[] => {
const agents: SubagentWithScope[] = data.agents.map( return agents.map(({ name, definition, source, filePath }: FilesystemAgent) => ({
({ name, definition, source, filePath }: FilesystemAgent) => ({ name,
name, definition,
definition, scope: source === 'user' ? 'global' : 'project',
scope: source === 'user' ? 'global' : 'project', type: 'filesystem' as const,
type: 'filesystem' as const, source,
source, filePath,
filePath, }));
}) }, [agents]);
);
setSubagentsWithScope(agents);
}
} catch (error) {
console.error('Failed to fetch filesystem agents:', error);
} finally {
setIsLoading(false);
}
}, [currentProject?.path]);
// Fetch filesystem agents on mount and when project changes // Refresh function that invalidates the query cache
useEffect(() => { const refreshFilesystemAgents = useCallback(async () => {
fetchFilesystemAgents(); await queryClient.invalidateQueries({
}, [fetchFilesystemAgents]); queryKey: queryKeys.settings.agents(currentProject?.path ?? ''),
});
await refetch();
}, [queryClient, currentProject?.path, refetch]);
return { return {
subagentsWithScope, subagentsWithScope,
isLoading, isLoading,
hasProject: !!currentProject, hasProject: !!currentProject,
refreshFilesystemAgents: fetchFilesystemAgents, refreshFilesystemAgents,
}; };
} }

View File

@@ -1,238 +1,77 @@
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status'; import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
import { OpencodeModelConfiguration } from './opencode-model-configuration'; import { OpencodeModelConfiguration } from './opencode-model-configuration';
import { getElectronAPI } from '@/lib/electron'; import { useOpencodeCliStatus, useOpencodeProviders, useOpencodeModels } from '@/hooks/queries';
import { createLogger } from '@automaker/utils/logger'; import { queryKeys } from '@/lib/query-keys';
import type { CliStatus as SharedCliStatus } from '../shared/types'; import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { OpencodeModelId } from '@automaker/types'; import type { OpencodeModelId } from '@automaker/types';
import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status'; import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
const logger = createLogger('OpencodeSettings');
const OPENCODE_PROVIDER_ID = 'opencode';
const OPENCODE_PROVIDER_SIGNATURE_SEPARATOR = '|';
const OPENCODE_STATIC_MODEL_PROVIDERS = new Set([OPENCODE_PROVIDER_ID]);
export function OpencodeSettingsTab() { export function OpencodeSettingsTab() {
const queryClient = useQueryClient();
const { const {
enabledOpencodeModels, enabledOpencodeModels,
opencodeDefaultModel, opencodeDefaultModel,
setOpencodeDefaultModel, setOpencodeDefaultModel,
toggleOpencodeModel, toggleOpencodeModel,
setDynamicOpencodeModels,
dynamicOpencodeModels,
enabledDynamicModelIds, enabledDynamicModelIds,
toggleDynamicModel, toggleDynamicModel,
cachedOpencodeProviders,
setCachedOpencodeProviders,
} = useAppStore(); } = useAppStore();
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false);
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const providerRefreshSignatureRef = useRef<string>('');
// Phase 1: Load CLI status quickly on mount // React Query hooks for data fetching
useEffect(() => { const {
const checkOpencodeStatus = async () => { data: cliStatusData,
setIsCheckingOpencodeCli(true); isLoading: isCheckingOpencodeCli,
try { refetch: refetchCliStatus,
const api = getElectronAPI(); } = useOpencodeCliStatus();
if (api?.setup?.getOpencodeStatus) {
const result = await api.setup.getOpencodeStatus(); const isCliInstalled = cliStatusData?.installed ?? false;
setCliStatus({
success: result.success, const { data: providersData = [], isFetching: isFetchingProviders } = useOpencodeProviders();
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method, const { data: modelsData = [], isFetching: isFetchingModels } = useOpencodeModels();
version: result.version,
path: result.path, // Transform CLI status to the expected format
recommendation: result.recommendation, const cliStatus = useMemo((): SharedCliStatus | null => {
installCommands: result.installCommands, if (!cliStatusData) return null;
}); return {
if (result.auth) { success: cliStatusData.success ?? false,
setAuthStatus({ status: cliStatusData.installed ? 'installed' : 'not_installed',
authenticated: result.auth.authenticated, method: cliStatusData.auth?.method,
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none', version: cliStatusData.version,
hasApiKey: result.auth.hasApiKey, path: cliStatusData.path,
hasEnvApiKey: result.auth.hasEnvApiKey, recommendation: cliStatusData.recommendation,
hasOAuthToken: result.auth.hasOAuthToken, installCommands: cliStatusData.installCommands,
});
}
} else {
setCliStatus({
success: false,
status: 'not_installed',
recommendation: 'OpenCode CLI detection is only available in desktop mode.',
});
}
} catch (error) {
logger.error('Failed to check OpenCode CLI status:', error);
setCliStatus({
success: false,
status: 'not_installed',
error: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCheckingOpencodeCli(false);
}
}; };
checkOpencodeStatus(); }, [cliStatusData]);
}, []);
// Phase 2: Load dynamic models and providers in background (only if not cached) // Transform auth status to the expected format
useEffect(() => { const authStatus = useMemo((): OpencodeAuthStatus | null => {
const loadDynamicContent = async () => { if (!cliStatusData?.auth) return null;
const api = getElectronAPI(); return {
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed'; authenticated: cliStatusData.auth.authenticated,
method: (cliStatusData.auth.method as OpencodeAuthStatus['method']) || 'none',
if (!isInstalled || !api?.setup) return; hasApiKey: cliStatusData.auth.hasApiKey,
hasEnvApiKey: cliStatusData.auth.hasEnvApiKey,
// Skip if already have cached data hasOAuthToken: cliStatusData.auth.hasOAuthToken,
const needsProviders = cachedOpencodeProviders.length === 0;
const needsModels = dynamicOpencodeModels.length === 0;
if (!needsProviders && !needsModels) return;
setIsLoadingDynamicModels(true);
try {
// Load providers if needed
if (needsProviders && api.setup.getOpencodeProviders) {
const providersResult = await api.setup.getOpencodeProviders();
if (providersResult.success && providersResult.providers) {
setCachedOpencodeProviders(providersResult.providers);
}
}
// Load models if needed
if (needsModels && api.setup.getOpencodeModels) {
const modelsResult = await api.setup.getOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
}
} catch (error) {
logger.error('Failed to load dynamic content:', error);
} finally {
setIsLoadingDynamicModels(false);
}
}; };
loadDynamicContent(); }, [cliStatusData]);
}, [cliStatus?.success, cliStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const refreshModelsForNewProviders = async () => {
const api = getElectronAPI();
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
if (!isInstalled || !api?.setup?.refreshOpencodeModels) return;
if (isLoadingDynamicModels) return;
const authenticatedProviders = cachedOpencodeProviders
.filter((provider) => provider.authenticated)
.map((provider) => provider.id)
.filter((providerId) => !OPENCODE_STATIC_MODEL_PROVIDERS.has(providerId));
if (authenticatedProviders.length === 0) {
providerRefreshSignatureRef.current = '';
return;
}
const dynamicProviderIds = new Set(
dynamicOpencodeModels.map((model) => model.provider).filter(Boolean)
);
const missingProviders = authenticatedProviders.filter(
(providerId) => !dynamicProviderIds.has(providerId)
);
if (missingProviders.length === 0) {
providerRefreshSignatureRef.current = '';
return;
}
const signature = [...missingProviders].sort().join(OPENCODE_PROVIDER_SIGNATURE_SEPARATOR);
if (providerRefreshSignatureRef.current === signature) return;
providerRefreshSignatureRef.current = signature;
setIsLoadingDynamicModels(true);
try {
const modelsResult = await api.setup.refreshOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
} catch (error) {
logger.error('Failed to refresh OpenCode models for new providers:', error);
} finally {
setIsLoadingDynamicModels(false);
}
};
refreshModelsForNewProviders();
}, [
cachedOpencodeProviders,
dynamicOpencodeModels,
cliStatus?.success,
cliStatus?.status,
isLoadingDynamicModels,
setDynamicOpencodeModels,
]);
// Refresh all opencode-related queries
const handleRefreshOpencodeCli = useCallback(async () => { const handleRefreshOpencodeCli = useCallback(async () => {
setIsCheckingOpencodeCli(true); await Promise.all([
setIsLoadingDynamicModels(true); queryClient.invalidateQueries({ queryKey: queryKeys.cli.opencode() }),
try { queryClient.invalidateQueries({ queryKey: queryKeys.models.opencodeProviders() }),
const api = getElectronAPI(); queryClient.invalidateQueries({ queryKey: queryKeys.models.opencode() }),
if (api?.setup?.getOpencodeStatus) { ]);
const result = await api.setup.getOpencodeStatus(); await refetchCliStatus();
setCliStatus({ toast.success('OpenCode CLI refreshed');
success: result.success, }, [queryClient, refetchCliStatus]);
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method,
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
hasApiKey: result.auth.hasApiKey,
hasEnvApiKey: result.auth.hasEnvApiKey,
hasOAuthToken: result.auth.hasOAuthToken,
});
}
if (result.installed) {
// Refresh providers
if (api?.setup?.getOpencodeProviders) {
const providersResult = await api.setup.getOpencodeProviders();
if (providersResult.success && providersResult.providers) {
setCachedOpencodeProviders(providersResult.providers);
}
}
// Refresh dynamic models
if (api?.setup?.refreshOpencodeModels) {
const modelsResult = await api.setup.refreshOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
}
toast.success('OpenCode CLI refreshed');
}
}
} catch (error) {
logger.error('Failed to refresh OpenCode CLI status:', error);
toast.error('Failed to refresh OpenCode CLI status');
} finally {
setIsCheckingOpencodeCli(false);
setIsLoadingDynamicModels(false);
}
}, [setDynamicOpencodeModels, setCachedOpencodeProviders]);
const handleDefaultModelChange = useCallback( const handleDefaultModelChange = useCallback(
(model: OpencodeModelId) => { (model: OpencodeModelId) => {
@@ -240,7 +79,7 @@ export function OpencodeSettingsTab() {
try { try {
setOpencodeDefaultModel(model); setOpencodeDefaultModel(model);
toast.success('Default model updated'); toast.success('Default model updated');
} catch (error) { } catch {
toast.error('Failed to update default model'); toast.error('Failed to update default model');
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@@ -254,7 +93,7 @@ export function OpencodeSettingsTab() {
setIsSaving(true); setIsSaving(true);
try { try {
toggleOpencodeModel(model, enabled); toggleOpencodeModel(model, enabled);
} catch (error) { } catch {
toast.error('Failed to update models'); toast.error('Failed to update models');
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@@ -268,7 +107,7 @@ export function OpencodeSettingsTab() {
setIsSaving(true); setIsSaving(true);
try { try {
toggleDynamicModel(modelId, enabled); toggleDynamicModel(modelId, enabled);
} catch (error) { } catch {
toast.error('Failed to update dynamic model'); toast.error('Failed to update dynamic model');
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@@ -286,14 +125,14 @@ export function OpencodeSettingsTab() {
); );
} }
const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed'; const isLoadingDynamicModels = isFetchingProviders || isFetchingModels;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<OpencodeCliStatus <OpencodeCliStatus
status={cliStatus} status={cliStatus}
authStatus={authStatus} authStatus={authStatus}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]} providers={providersData as OpenCodeProviderInfo[]}
isChecking={isCheckingOpencodeCli} isChecking={isCheckingOpencodeCli}
onRefresh={handleRefreshOpencodeCli} onRefresh={handleRefreshOpencodeCli}
/> />
@@ -306,8 +145,8 @@ export function OpencodeSettingsTab() {
isSaving={isSaving} isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange} onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle} onModelToggle={handleModelToggle}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]} providers={providersData as OpenCodeProviderInfo[]}
dynamicModels={dynamicOpencodeModels} dynamicModels={modelsData}
enabledDynamicModelIds={enabledDynamicModelIds} enabledDynamicModelIds={enabledDynamicModelIds}
onDynamicModelToggle={handleDynamicModelToggle} onDynamicModelToggle={handleDynamicModelToggle}
isLoadingDynamicModels={isLoadingDynamicModels} isLoadingDynamicModels={isLoadingDynamicModels}

View File

@@ -14,24 +14,16 @@ import {
PanelBottomClose, PanelBottomClose,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from '@/lib/http-api-client';
import { useWorktreeInitScript } from '@/hooks/queries';
import { useSetInitScript, useDeleteInitScript } from '@/hooks/mutations';
interface WorktreesSectionProps { interface WorktreesSectionProps {
useWorktrees: boolean; useWorktrees: boolean;
onUseWorktreesChange: (value: boolean) => void; onUseWorktreesChange: (value: boolean) => void;
} }
interface InitScriptResponse {
success: boolean;
exists: boolean;
content: string;
path: string;
error?: string;
}
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) { export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
const currentProject = useAppStore((s) => s.currentProject); const currentProject = useAppStore((s) => s.currentProject);
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator); const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
@@ -40,12 +32,20 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch); const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator); const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator); const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
// Local state for script content editing
const [scriptContent, setScriptContent] = useState(''); const [scriptContent, setScriptContent] = useState('');
const [originalContent, setOriginalContent] = useState(''); const [originalContent, setOriginalContent] = useState('');
const [scriptExists, setScriptExists] = useState(false);
const [isLoading, setIsLoading] = useState(true); // React Query hooks for init script
const [isSaving, setIsSaving] = useState(false); const { data: initScriptData, isLoading } = useWorktreeInitScript(currentProject?.path);
const [isDeleting, setIsDeleting] = useState(false); const setInitScript = useSetInitScript(currentProject?.path ?? '');
const deleteInitScript = useDeleteInitScript(currentProject?.path ?? '');
// Derived state
const scriptExists = initScriptData?.exists ?? false;
const isSaving = setInitScript.isPending;
const isDeleting = deleteInitScript.isPending;
// Get the current show indicator setting // Get the current show indicator setting
const showIndicator = currentProject?.path const showIndicator = currentProject?.path
@@ -65,102 +65,43 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
// Check if there are unsaved changes // Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent; const hasChanges = scriptContent !== originalContent;
// Load init script content when project changes // Sync query data to local state when it changes
useEffect(() => { useEffect(() => {
if (!currentProject?.path) { if (initScriptData) {
const content = initScriptData.content || '';
setScriptContent(content);
setOriginalContent(content);
} else if (!currentProject?.path) {
setScriptContent(''); setScriptContent('');
setOriginalContent(''); setOriginalContent('');
setScriptExists(false);
setIsLoading(false);
return;
} }
}, [initScriptData, currentProject?.path]);
const loadInitScript = async () => { // Save script using mutation
setIsLoading(true); const handleSave = useCallback(() => {
try {
const response = await apiGet<InitScriptResponse>(
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}`
);
if (response.success) {
const content = response.content || '';
setScriptContent(content);
setOriginalContent(content);
setScriptExists(response.exists);
}
} catch (error) {
console.error('Failed to load init script:', error);
} finally {
setIsLoading(false);
}
};
loadInitScript();
}, [currentProject?.path]);
// Save script
const handleSave = useCallback(async () => {
if (!currentProject?.path) return; if (!currentProject?.path) return;
setInitScript.mutate(scriptContent, {
setIsSaving(true); onSuccess: () => {
try {
const response = await apiPut<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
projectPath: currentProject.path,
content: scriptContent,
}
);
if (response.success) {
setOriginalContent(scriptContent); setOriginalContent(scriptContent);
setScriptExists(true); },
toast.success('Init script saved'); });
} else { }, [currentProject?.path, scriptContent, setInitScript]);
toast.error('Failed to save init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to save init script:', error);
toast.error('Failed to save init script');
} finally {
setIsSaving(false);
}
}, [currentProject?.path, scriptContent]);
// Reset to original content // Reset to original content
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setScriptContent(originalContent); setScriptContent(originalContent);
}, [originalContent]); }, [originalContent]);
// Delete script // Delete script using mutation
const handleDelete = useCallback(async () => { const handleDelete = useCallback(() => {
if (!currentProject?.path) return; if (!currentProject?.path) return;
deleteInitScript.mutate(undefined, {
setIsDeleting(true); onSuccess: () => {
try {
const response = await apiDelete<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
body: { projectPath: currentProject.path },
}
);
if (response.success) {
setScriptContent(''); setScriptContent('');
setOriginalContent(''); setOriginalContent('');
setScriptExists(false); },
toast.success('Init script deleted'); });
} else { }, [currentProject?.path, deleteInitScript]);
toast.error('Failed to delete init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to delete init script:', error);
toast.error('Failed to delete init script');
} finally {
setIsDeleting(false);
}
}, [currentProject?.path]);
// Handle content change (no auto-save) // Handle content change (no auto-save)
const handleContentChange = useCallback((value: string) => { const handleContentChange = useCallback((value: string) => {