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

View File

@@ -1,19 +1,16 @@
// @ts-nocheck
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertCircle } from 'lucide-react';
import { OpenAIIcon } from '@/components/ui/provider-icon';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import {
formatCodexPlanType,
formatCodexResetTime,
getCodexWindowLabel,
} from '@/lib/codex-usage-format';
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_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
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 =
'Usage limits are not available yet. Try refreshing if this persists.';
const UPDATED_LABEL = 'Updated';
const CODEX_FETCH_ERROR = 'Failed to fetch usage';
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
const PLAN_LABEL = 'Plan';
const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100;
const REFRESH_INTERVAL_MS = 60_000;
const STALE_THRESHOLD_MS = 2 * 60_000;
const USAGE_COLOR_CRITICAL = 'bg-red-500';
const USAGE_COLOR_WARNING = 'bg-amber-500';
const USAGE_COLOR_OK = 'bg-emerald-500';
@@ -39,11 +33,12 @@ const isRateLimitWindow = (
export function CodexUsageSection() {
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;
// Use React Query for data fetching with automatic polling
const { data: codexUsage, isLoading, isFetching, error, refetch } = useCodexUsage(canFetchUsage);
const rateLimits = codexUsage?.rateLimits ?? null;
const primary = rateLimits?.primary ?? null;
const secondary = rateLimits?.secondary ?? null;
@@ -54,46 +49,7 @@ export function CodexUsageSection() {
? new Date(codexUsage.lastUpdated).toLocaleString()
: null;
const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading;
const isStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > STALE_THRESHOLD_MS;
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 errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
const getUsageColor = (percentage: number) => {
if (percentage >= WARNING_THRESHOLD) {
@@ -162,13 +118,13 @@ export function CodexUsageSection() {
<Button
variant="ghost"
size="icon"
onClick={fetchUsage}
disabled={isLoading}
onClick={() => refetch()}
disabled={isFetching}
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
data-testid="refresh-codex-usage"
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>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>
@@ -182,10 +138,10 @@ export function CodexUsageSection() {
</div>
</div>
)}
{error && (
{errorMessage && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
<div className="text-sm text-red-400">{error}</div>
<div className="text-sm text-red-400">{errorMessage}</div>
</div>
)}
{hasMetrics && (
@@ -210,7 +166,7 @@ export function CodexUsageSection() {
</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">
{CODEX_NO_USAGE_MESSAGE}
</div>

View File

@@ -1,103 +1,52 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { toast } from 'sonner';
import { useState, useCallback, useEffect } from 'react';
import { useCursorPermissionsQuery, type CursorPermissionsData } from '@/hooks/queries';
import { useApplyCursorProfile, useCopyCursorConfig } from '@/hooks/mutations';
const logger = createLogger('CursorPermissions');
import { getHttpApiClient } from '@/lib/http-api-client';
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[] };
}>;
}
// Re-export for backward compatibility
export type PermissionsData = CursorPermissionsData;
/**
* Custom hook for managing Cursor CLI permissions
* Handles loading permissions data, applying profiles, and copying configs
*/
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);
// Load permissions data
const loadPermissions = useCallback(async () => {
setIsLoadingPermissions(true);
try {
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]);
// React Query hooks
const permissionsQuery = useCursorPermissionsQuery(projectPath);
const applyProfileMutation = useApplyCursorProfile(projectPath);
const copyConfigMutation = useCopyCursorConfig();
// Apply a permission profile
const applyProfile = useCallback(
async (profileId: 'strict' | 'development', scope: 'global' | 'project') => {
setIsSavingPermissions(true);
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);
}
(profileId: 'strict' | 'development', scope: 'global' | 'project') => {
applyProfileMutation.mutate({ profileId, scope });
},
[projectPath, loadPermissions]
[applyProfileMutation]
);
// Copy example config to clipboard
const copyConfig = useCallback(async (profileId: 'strict' | 'development') => {
try {
const api = getHttpApiClient();
const result = await api.setup.getCursorExampleConfig(profileId);
const copyConfig = useCallback(
(profileId: 'strict' | 'development') => {
copyConfigMutation.mutate(profileId, {
onSuccess: () => {
setCopiedConfig(true);
setTimeout(() => setCopiedConfig(false), 2000);
},
});
},
[copyConfigMutation]
);
if (result.success && result.config) {
await navigator.clipboard.writeText(result.config);
setCopiedConfig(true);
toast.success('Config copied to clipboard');
setTimeout(() => setCopiedConfig(false), 2000);
}
} catch (error) {
toast.error('Failed to copy config');
}
}, []);
// Load permissions (refetch)
const loadPermissions = useCallback(() => {
permissionsQuery.refetch();
}, [permissionsQuery]);
return {
permissions,
isLoadingPermissions,
isSavingPermissions,
permissions: permissionsQuery.data ?? null,
isLoadingPermissions: permissionsQuery.isLoading,
isSavingPermissions: applyProfileMutation.isPending,
copiedConfig,
loadPermissions,
applyProfile,

View File

@@ -1,9 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { toast } from 'sonner';
const logger = createLogger('CursorStatus');
import { getHttpApiClient } from '@/lib/http-api-client';
import { useEffect, useMemo, useCallback } from 'react';
import { useCursorCliStatus } from '@/hooks/queries';
import { useSetupStore } from '@/store/setup-store';
export interface CursorStatus {
@@ -15,52 +11,42 @@ export interface CursorStatus {
/**
* 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() {
const { setCursorCliStatus } = useSetupStore();
const { data: result, isLoading, refetch } = useCursorCliStatus();
const [status, setStatus] = useState<CursorStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const api = getHttpApiClient();
const statusResult = await api.setup.getCursorStatus();
if (statusResult.success) {
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]);
// Transform the API result into the local CursorStatus shape
const status = useMemo((): CursorStatus | null => {
if (!result) return null;
return {
installed: result.installed ?? false,
version: result.version ?? undefined,
authenticated: result.auth?.authenticated ?? false,
method: result.auth?.method,
};
}, [result]);
// Keep the global setup store in sync with query data
useEffect(() => {
loadData();
}, [loadData]);
if (status) {
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 {
status,

View File

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

View File

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

View File

@@ -9,10 +9,12 @@
* 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 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 SubagentType = 'filesystem';
@@ -35,51 +37,40 @@ interface FilesystemAgent {
}
export function useSubagents() {
const queryClient = useQueryClient();
const currentProject = useAppStore((state) => state.currentProject);
const [isLoading, setIsLoading] = useState(false);
const [subagentsWithScope, setSubagentsWithScope] = useState<SubagentWithScope[]>([]);
// Fetch filesystem agents
const fetchFilesystemAgents = useCallback(async () => {
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api.settings) {
console.warn('Settings API not available');
return;
}
const data = await api.settings.discoverAgents(currentProject?.path, ['user', 'project']);
// Use React Query hook for fetching agents
const {
data: agents = [],
isLoading,
refetch,
} = useDiscoveredAgents(currentProject?.path, ['user', 'project']);
if (data.success && data.agents) {
// Transform filesystem agents to SubagentWithScope format
const agents: SubagentWithScope[] = data.agents.map(
({ name, definition, source, filePath }: FilesystemAgent) => ({
name,
definition,
scope: source === 'user' ? 'global' : 'project',
type: 'filesystem' as const,
source,
filePath,
})
);
setSubagentsWithScope(agents);
}
} catch (error) {
console.error('Failed to fetch filesystem agents:', error);
} finally {
setIsLoading(false);
}
}, [currentProject?.path]);
// Transform agents to SubagentWithScope format
const subagentsWithScope = useMemo((): SubagentWithScope[] => {
return agents.map(({ name, definition, source, filePath }: FilesystemAgent) => ({
name,
definition,
scope: source === 'user' ? 'global' : 'project',
type: 'filesystem' as const,
source,
filePath,
}));
}, [agents]);
// Fetch filesystem agents on mount and when project changes
useEffect(() => {
fetchFilesystemAgents();
}, [fetchFilesystemAgents]);
// Refresh function that invalidates the query cache
const refreshFilesystemAgents = useCallback(async () => {
await queryClient.invalidateQueries({
queryKey: queryKeys.settings.agents(currentProject?.path ?? ''),
});
await refetch();
}, [queryClient, currentProject?.path, refetch]);
return {
subagentsWithScope,
isLoading,
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 { useAppStore } from '@/store/app-store';
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
import { OpencodeModelConfiguration } from './opencode-model-configuration';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import { useOpencodeCliStatus, useOpencodeProviders, useOpencodeModels } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { OpencodeModelId } from '@automaker/types';
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() {
const queryClient = useQueryClient();
const {
enabledOpencodeModels,
opencodeDefaultModel,
setOpencodeDefaultModel,
toggleOpencodeModel,
setDynamicOpencodeModels,
dynamicOpencodeModels,
enabledDynamicModelIds,
toggleDynamicModel,
cachedOpencodeProviders,
setCachedOpencodeProviders,
} = 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 providerRefreshSignatureRef = useRef<string>('');
// Phase 1: Load CLI status quickly on mount
useEffect(() => {
const checkOpencodeStatus = async () => {
setIsCheckingOpencodeCli(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
const result = await api.setup.getOpencodeStatus();
setCliStatus({
success: result.success,
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,
});
}
} 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);
}
// React Query hooks for data fetching
const {
data: cliStatusData,
isLoading: isCheckingOpencodeCli,
refetch: refetchCliStatus,
} = useOpencodeCliStatus();
const isCliInstalled = cliStatusData?.installed ?? false;
const { data: providersData = [], isFetching: isFetchingProviders } = useOpencodeProviders();
const { data: modelsData = [], isFetching: isFetchingModels } = useOpencodeModels();
// Transform CLI status to the expected format
const cliStatus = useMemo((): SharedCliStatus | null => {
if (!cliStatusData) return null;
return {
success: cliStatusData.success ?? false,
status: cliStatusData.installed ? 'installed' : 'not_installed',
method: cliStatusData.auth?.method,
version: cliStatusData.version,
path: cliStatusData.path,
recommendation: cliStatusData.recommendation,
installCommands: cliStatusData.installCommands,
};
checkOpencodeStatus();
}, []);
}, [cliStatusData]);
// Phase 2: Load dynamic models and providers in background (only if not cached)
useEffect(() => {
const loadDynamicContent = async () => {
const api = getElectronAPI();
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
if (!isInstalled || !api?.setup) return;
// Skip if already have cached data
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);
}
// Transform auth status to the expected format
const authStatus = useMemo((): OpencodeAuthStatus | null => {
if (!cliStatusData?.auth) return null;
return {
authenticated: cliStatusData.auth.authenticated,
method: (cliStatusData.auth.method as OpencodeAuthStatus['method']) || 'none',
hasApiKey: cliStatusData.auth.hasApiKey,
hasEnvApiKey: cliStatusData.auth.hasEnvApiKey,
hasOAuthToken: cliStatusData.auth.hasOAuthToken,
};
loadDynamicContent();
}, [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,
]);
}, [cliStatusData]);
// Refresh all opencode-related queries
const handleRefreshOpencodeCli = useCallback(async () => {
setIsCheckingOpencodeCli(true);
setIsLoadingDynamicModels(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
const result = await api.setup.getOpencodeStatus();
setCliStatus({
success: result.success,
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]);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.cli.opencode() }),
queryClient.invalidateQueries({ queryKey: queryKeys.models.opencodeProviders() }),
queryClient.invalidateQueries({ queryKey: queryKeys.models.opencode() }),
]);
await refetchCliStatus();
toast.success('OpenCode CLI refreshed');
}, [queryClient, refetchCliStatus]);
const handleDefaultModelChange = useCallback(
(model: OpencodeModelId) => {
@@ -240,7 +79,7 @@ export function OpencodeSettingsTab() {
try {
setOpencodeDefaultModel(model);
toast.success('Default model updated');
} catch (error) {
} catch {
toast.error('Failed to update default model');
} finally {
setIsSaving(false);
@@ -254,7 +93,7 @@ export function OpencodeSettingsTab() {
setIsSaving(true);
try {
toggleOpencodeModel(model, enabled);
} catch (error) {
} catch {
toast.error('Failed to update models');
} finally {
setIsSaving(false);
@@ -268,7 +107,7 @@ export function OpencodeSettingsTab() {
setIsSaving(true);
try {
toggleDynamicModel(modelId, enabled);
} catch (error) {
} catch {
toast.error('Failed to update dynamic model');
} finally {
setIsSaving(false);
@@ -286,14 +125,14 @@ export function OpencodeSettingsTab() {
);
}
const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed';
const isLoadingDynamicModels = isFetchingProviders || isFetchingModels;
return (
<div className="space-y-6">
<OpencodeCliStatus
status={cliStatus}
authStatus={authStatus}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
providers={providersData as OpenCodeProviderInfo[]}
isChecking={isCheckingOpencodeCli}
onRefresh={handleRefreshOpencodeCli}
/>
@@ -306,8 +145,8 @@ export function OpencodeSettingsTab() {
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
dynamicModels={dynamicOpencodeModels}
providers={providersData as OpenCodeProviderInfo[]}
dynamicModels={modelsData}
enabledDynamicModelIds={enabledDynamicModelIds}
onDynamicModelToggle={handleDynamicModelToggle}
isLoadingDynamicModels={isLoadingDynamicModels}

View File

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