mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user