mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
feat: Add process abort control and improve auth detection
This commit is contained in:
@@ -6,6 +6,7 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useCodexUsage } from '@/hooks/queries';
|
||||
import { getExpectedCodexPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
@@ -100,13 +101,28 @@ export function CodexUsagePopover() {
|
||||
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
|
||||
};
|
||||
|
||||
// Helper component for the progress bar
|
||||
const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => (
|
||||
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
|
||||
// Helper component for the progress bar with optional pace indicator
|
||||
const ProgressBar = ({
|
||||
percentage,
|
||||
colorClass,
|
||||
pacePercentage,
|
||||
}: {
|
||||
percentage: number;
|
||||
colorClass: string;
|
||||
pacePercentage?: number | null;
|
||||
}) => (
|
||||
<div className="relative h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full transition-all duration-500', colorClass)}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
{pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && (
|
||||
<div
|
||||
className="absolute top-0 h-full w-0.5 bg-foreground/60"
|
||||
style={{ left: `${pacePercentage}%` }}
|
||||
title={`Expected: ${Math.round(pacePercentage)}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -117,6 +133,7 @@ export function CodexUsagePopover() {
|
||||
resetText,
|
||||
isPrimary = false,
|
||||
stale = false,
|
||||
pacePercentage,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -124,6 +141,7 @@ export function CodexUsagePopover() {
|
||||
resetText?: string;
|
||||
isPrimary?: boolean;
|
||||
stale?: boolean;
|
||||
pacePercentage?: number | null;
|
||||
}) => {
|
||||
const isValidPercentage =
|
||||
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
||||
@@ -131,6 +149,10 @@ export function CodexUsagePopover() {
|
||||
|
||||
const status = getStatusInfo(safePercentage);
|
||||
const StatusIcon = status.icon;
|
||||
const paceLabel =
|
||||
isValidPercentage && pacePercentage != null
|
||||
? getPaceStatusLabel(safePercentage, pacePercentage)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -165,15 +187,28 @@ export function CodexUsagePopover() {
|
||||
<ProgressBar
|
||||
percentage={safePercentage}
|
||||
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
|
||||
pacePercentage={pacePercentage}
|
||||
/>
|
||||
{resetText && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
{paceLabel ? (
|
||||
<p
|
||||
className={cn(
|
||||
'text-[10px] font-medium',
|
||||
safePercentage > (pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500'
|
||||
)}
|
||||
>
|
||||
{paceLabel}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{resetText && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{resetText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -289,6 +324,10 @@ export function CodexUsagePopover() {
|
||||
resetText={formatResetTime(codexUsage.rateLimits.primary.resetsAt)}
|
||||
isPrimary={true}
|
||||
stale={isStale}
|
||||
pacePercentage={getExpectedCodexPacePercentage(
|
||||
codexUsage.rateLimits.primary.resetsAt,
|
||||
codexUsage.rateLimits.primary.windowDurationMins
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -302,6 +341,10 @@ export function CodexUsagePopover() {
|
||||
percentage={codexUsage.rateLimits.secondary.usedPercent}
|
||||
resetText={formatResetTime(codexUsage.rateLimits.secondary.resetsAt)}
|
||||
stale={isStale}
|
||||
pacePercentage={getExpectedCodexPacePercentage(
|
||||
codexUsage.rateLimits.secondary.resetsAt,
|
||||
codexUsage.rateLimits.secondary.windowDurationMins
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -524,234 +524,153 @@ export function GitDiffPanel({
|
||||
setExpandedFiles(new Set());
|
||||
};
|
||||
|
||||
// Stage/unstage a single file
|
||||
const handleStageFile = useCallback(
|
||||
async (filePath: string) => {
|
||||
// Shared helper that encapsulates all staging/unstaging logic
|
||||
const executeStagingAction = useCallback(
|
||||
async (
|
||||
action: 'stage' | 'unstage',
|
||||
paths: string[],
|
||||
successMessage: string,
|
||||
failurePrefix: string,
|
||||
onStart: () => void,
|
||||
onFinally: () => void
|
||||
) => {
|
||||
if (!worktreePath && !projectPath) return;
|
||||
if (enableStaging && useWorktrees && !worktreePath) {
|
||||
toast.error('Failed to stage file', {
|
||||
description: 'worktreePath required when useWorktrees is enabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setStagingInProgress((prev) => new Set(prev).add(filePath));
|
||||
onStart();
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
let result: { success: boolean; error?: string } | undefined;
|
||||
|
||||
if (useWorktrees && worktreePath) {
|
||||
if (!api.worktree?.stageFiles) {
|
||||
toast.error('Failed to stage file', {
|
||||
toast.error(failurePrefix, {
|
||||
description: 'Worktree stage API not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
result = await api.worktree.stageFiles(worktreePath, [filePath], 'stage');
|
||||
result = await api.worktree.stageFiles(worktreePath, paths, action);
|
||||
} else if (!useWorktrees) {
|
||||
if (!api.git?.stageFiles) {
|
||||
toast.error('Failed to stage file', { description: 'Git stage API not available' });
|
||||
toast.error(failurePrefix, { description: 'Git stage API not available' });
|
||||
return;
|
||||
}
|
||||
result = await api.git.stageFiles(projectPath, [filePath], 'stage');
|
||||
result = await api.git.stageFiles(projectPath, paths, action);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
toast.error('Failed to stage file', { description: 'Stage API not available' });
|
||||
toast.error(failurePrefix, { description: 'Stage API not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error('Failed to stage file', { description: result.error });
|
||||
toast.error(failurePrefix, { description: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// Refetch diffs to reflect the new staging state
|
||||
await loadDiffs();
|
||||
toast.success('File staged', { description: filePath });
|
||||
toast.success(successMessage, paths.length === 1 ? { description: paths[0] } : undefined);
|
||||
} catch (err) {
|
||||
toast.error('Failed to stage file', {
|
||||
toast.error(failurePrefix, {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setStagingInProgress((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(filePath);
|
||||
return next;
|
||||
});
|
||||
onFinally();
|
||||
}
|
||||
},
|
||||
[worktreePath, projectPath, useWorktrees, enableStaging, loadDiffs]
|
||||
[worktreePath, projectPath, useWorktrees, loadDiffs]
|
||||
);
|
||||
|
||||
// Stage/unstage a single file
|
||||
const handleStageFile = useCallback(
|
||||
async (filePath: string) => {
|
||||
if (enableStaging && useWorktrees && !worktreePath) {
|
||||
toast.error('Failed to stage file', {
|
||||
description: 'worktreePath required when useWorktrees is enabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
await executeStagingAction(
|
||||
'stage',
|
||||
[filePath],
|
||||
'File staged',
|
||||
'Failed to stage file',
|
||||
() => setStagingInProgress((prev) => new Set(prev).add(filePath)),
|
||||
() =>
|
||||
setStagingInProgress((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(filePath);
|
||||
return next;
|
||||
})
|
||||
);
|
||||
},
|
||||
[worktreePath, useWorktrees, enableStaging, executeStagingAction]
|
||||
);
|
||||
|
||||
// Unstage a single file
|
||||
const handleUnstageFile = useCallback(
|
||||
async (filePath: string) => {
|
||||
if (!worktreePath && !projectPath) return;
|
||||
if (enableStaging && useWorktrees && !worktreePath) {
|
||||
toast.error('Failed to unstage file', {
|
||||
description: 'worktreePath required when useWorktrees is enabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setStagingInProgress((prev) => new Set(prev).add(filePath));
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
let result: { success: boolean; error?: string } | undefined;
|
||||
|
||||
if (useWorktrees && worktreePath) {
|
||||
if (!api.worktree?.stageFiles) {
|
||||
toast.error('Failed to unstage file', {
|
||||
description: 'Worktree stage API not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
result = await api.worktree.stageFiles(worktreePath, [filePath], 'unstage');
|
||||
} else if (!useWorktrees) {
|
||||
if (!api.git?.stageFiles) {
|
||||
toast.error('Failed to unstage file', { description: 'Git stage API not available' });
|
||||
return;
|
||||
}
|
||||
result = await api.git.stageFiles(projectPath, [filePath], 'unstage');
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
toast.error('Failed to unstage file', { description: 'Stage API not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error('Failed to unstage file', { description: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// Refetch diffs to reflect the new staging state
|
||||
await loadDiffs();
|
||||
toast.success('File unstaged', { description: filePath });
|
||||
} catch (err) {
|
||||
toast.error('Failed to unstage file', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setStagingInProgress((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(filePath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
await executeStagingAction(
|
||||
'unstage',
|
||||
[filePath],
|
||||
'File unstaged',
|
||||
'Failed to unstage file',
|
||||
() => setStagingInProgress((prev) => new Set(prev).add(filePath)),
|
||||
() =>
|
||||
setStagingInProgress((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(filePath);
|
||||
return next;
|
||||
})
|
||||
);
|
||||
},
|
||||
[worktreePath, projectPath, useWorktrees, enableStaging, loadDiffs]
|
||||
[worktreePath, useWorktrees, enableStaging, executeStagingAction]
|
||||
);
|
||||
|
||||
const handleStageAll = useCallback(async () => {
|
||||
if (!worktreePath && !projectPath) return;
|
||||
const allPaths = files.map((f) => f.path);
|
||||
if (allPaths.length === 0) return;
|
||||
setStagingInProgress(new Set(allPaths));
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
let result: { success: boolean; error?: string } | undefined;
|
||||
|
||||
if (useWorktrees && worktreePath) {
|
||||
if (!api.worktree?.stageFiles) {
|
||||
toast.error('Failed to stage all files', {
|
||||
description: 'Worktree stage API not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
result = await api.worktree.stageFiles(worktreePath, allPaths, 'stage');
|
||||
} else if (!useWorktrees) {
|
||||
if (!api.git?.stageFiles) {
|
||||
toast.error('Failed to stage all files', { description: 'Git stage API not available' });
|
||||
return;
|
||||
}
|
||||
result = await api.git.stageFiles(projectPath, allPaths, 'stage');
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
toast.error('Failed to stage all files', { description: 'Stage API not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error('Failed to stage all files', { description: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
await loadDiffs();
|
||||
toast.success('All files staged');
|
||||
} catch (err) {
|
||||
toast.error('Failed to stage all files', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setStagingInProgress(new Set());
|
||||
}
|
||||
}, [worktreePath, projectPath, useWorktrees, files, loadDiffs]);
|
||||
await executeStagingAction(
|
||||
'stage',
|
||||
allPaths,
|
||||
'All files staged',
|
||||
'Failed to stage all files',
|
||||
() => setStagingInProgress(new Set(allPaths)),
|
||||
() => setStagingInProgress(new Set())
|
||||
);
|
||||
}, [worktreePath, projectPath, useWorktrees, files, executeStagingAction]);
|
||||
|
||||
const handleUnstageAll = useCallback(async () => {
|
||||
if (!worktreePath && !projectPath) return;
|
||||
const allPaths = files.map((f) => f.path);
|
||||
if (allPaths.length === 0) return;
|
||||
setStagingInProgress(new Set(allPaths));
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
let result: { success: boolean; error?: string } | undefined;
|
||||
|
||||
if (useWorktrees && worktreePath) {
|
||||
if (!api.worktree?.stageFiles) {
|
||||
toast.error('Failed to unstage all files', {
|
||||
description: 'Worktree stage API not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
result = await api.worktree.stageFiles(worktreePath, allPaths, 'unstage');
|
||||
} else if (!useWorktrees) {
|
||||
if (!api.git?.stageFiles) {
|
||||
toast.error('Failed to unstage all files', {
|
||||
description: 'Git stage API not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
result = await api.git.stageFiles(projectPath, allPaths, 'unstage');
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
toast.error('Failed to unstage all files', { description: 'Stage API not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error('Failed to unstage all files', { description: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
await loadDiffs();
|
||||
toast.success('All files unstaged');
|
||||
} catch (err) {
|
||||
toast.error('Failed to unstage all files', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setStagingInProgress(new Set());
|
||||
}
|
||||
}, [worktreePath, projectPath, useWorktrees, files, loadDiffs]);
|
||||
await executeStagingAction(
|
||||
'unstage',
|
||||
allPaths,
|
||||
'All files unstaged',
|
||||
'Failed to unstage all files',
|
||||
() => setStagingInProgress(new Set(allPaths)),
|
||||
() => setStagingInProgress(new Set())
|
||||
);
|
||||
}, [worktreePath, projectPath, useWorktrees, files, executeStagingAction]);
|
||||
|
||||
// Compute staging summary
|
||||
const stagingSummary = useMemo(() => {
|
||||
if (!enableStaging) return null;
|
||||
let staged = 0;
|
||||
let partial = 0;
|
||||
let unstaged = 0;
|
||||
for (const file of files) {
|
||||
const state = getStagingState(file);
|
||||
if (state === 'staged') staged++;
|
||||
else if (state === 'unstaged') unstaged++;
|
||||
else {
|
||||
// partial counts as both
|
||||
staged++;
|
||||
unstaged++;
|
||||
}
|
||||
else partial++;
|
||||
}
|
||||
return { staged, unstaged, total: files.length };
|
||||
return { staged, partial, unstaged, total: files.length };
|
||||
}, [enableStaging, files]);
|
||||
|
||||
// Total stats
|
||||
@@ -884,7 +803,10 @@ export function GitDiffPanel({
|
||||
size="sm"
|
||||
onClick={handleStageAll}
|
||||
className="text-xs h-7"
|
||||
disabled={stagingInProgress.size > 0 || stagingSummary.unstaged === 0}
|
||||
disabled={
|
||||
stagingInProgress.size > 0 ||
|
||||
(stagingSummary.unstaged === 0 && stagingSummary.partial === 0)
|
||||
}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Stage All
|
||||
@@ -894,7 +816,10 @@ export function GitDiffPanel({
|
||||
size="sm"
|
||||
onClick={handleUnstageAll}
|
||||
className="text-xs h-7"
|
||||
disabled={stagingInProgress.size > 0 || stagingSummary.staged === 0}
|
||||
disabled={
|
||||
stagingInProgress.size > 0 ||
|
||||
(stagingSummary.staged === 0 && stagingSummary.partial === 0)
|
||||
}
|
||||
>
|
||||
<Minus className="w-3 h-3 mr-1" />
|
||||
Unstage All
|
||||
@@ -942,7 +867,9 @@ export function GitDiffPanel({
|
||||
)}
|
||||
{enableStaging && stagingSummary && (
|
||||
<span className="text-muted-foreground">
|
||||
({stagingSummary.staged} staged, {stagingSummary.unstaged} unstaged)
|
||||
{stagingSummary.partial > 0
|
||||
? `(${stagingSummary.staged} staged, ${stagingSummary.partial} partial, ${stagingSummary.unstaged} unstaged)`
|
||||
: `(${stagingSummary.staged} staged, ${stagingSummary.unstaged} unstaged)`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,11 @@ import { cn } from '@/lib/utils';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon';
|
||||
import { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } from '@/hooks/queries';
|
||||
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||
import {
|
||||
getExpectedWeeklyPacePercentage,
|
||||
getExpectedCodexPacePercentage,
|
||||
getPaceStatusLabel,
|
||||
} from '@/store/utils/usage-utils';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
@@ -683,6 +687,10 @@ export function UsagePopover() {
|
||||
resetText={formatCodexResetTime(codexUsage.rateLimits.primary.resetsAt)}
|
||||
isPrimary={true}
|
||||
stale={isCodexStale}
|
||||
pacePercentage={getExpectedCodexPacePercentage(
|
||||
codexUsage.rateLimits.primary.resetsAt,
|
||||
codexUsage.rateLimits.primary.windowDurationMins
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -699,6 +707,10 @@ export function UsagePopover() {
|
||||
percentage={codexUsage.rateLimits.secondary.usedPercent}
|
||||
resetText={formatCodexResetTime(codexUsage.rateLimits.secondary.resetsAt)}
|
||||
stale={isCodexStale}
|
||||
pacePercentage={getExpectedCodexPacePercentage(
|
||||
codexUsage.rateLimits.secondary.resetsAt,
|
||||
codexUsage.rateLimits.secondary.windowDurationMins
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -103,8 +103,6 @@ const getStatusBadgeColor = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// parseDiff is imported from @/lib/diff-utils
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
content,
|
||||
@@ -236,7 +234,12 @@ export function CommitWorktreeDialog({
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load diffs for commit dialog:', err);
|
||||
console.error('Failed to load diffs for commit dialog:', err);
|
||||
if (!cancelled) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to load diffs';
|
||||
setError(errorMsg);
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoadingDiffs(false);
|
||||
}
|
||||
|
||||
@@ -102,7 +102,26 @@ const getStatusBadgeColor = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// parseDiff is imported from @/lib/diff-utils
|
||||
const bgClass = {
|
||||
context: 'bg-transparent',
|
||||
addition: 'bg-green-500/10',
|
||||
deletion: 'bg-red-500/10',
|
||||
header: 'bg-blue-500/10',
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
context: 'text-foreground-secondary',
|
||||
addition: 'text-green-400',
|
||||
deletion: 'text-red-400',
|
||||
header: 'text-blue-400',
|
||||
};
|
||||
|
||||
const prefix = {
|
||||
context: ' ',
|
||||
addition: '+',
|
||||
deletion: '-',
|
||||
header: '',
|
||||
};
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
@@ -113,27 +132,6 @@ function DiffLine({
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}) {
|
||||
const bgClass = {
|
||||
context: 'bg-transparent',
|
||||
addition: 'bg-green-500/10',
|
||||
deletion: 'bg-red-500/10',
|
||||
header: 'bg-blue-500/10',
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
context: 'text-foreground-secondary',
|
||||
addition: 'text-green-400',
|
||||
deletion: 'text-red-400',
|
||||
header: 'text-blue-400',
|
||||
};
|
||||
|
||||
const prefix = {
|
||||
context: ' ',
|
||||
addition: '+',
|
||||
deletion: '-',
|
||||
header: '',
|
||||
};
|
||||
|
||||
if (type === 'header') {
|
||||
return (
|
||||
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
|
||||
@@ -332,6 +330,7 @@ export function DiscardWorktreeChangesDialog({
|
||||
</Label>
|
||||
{files.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleAll}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
@@ -368,6 +367,8 @@ export function DiscardWorktreeChangesDialog({
|
||||
)
|
||||
: 0;
|
||||
|
||||
const fileButtonId = `file-btn-${file.path.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
|
||||
return (
|
||||
<div key={file.path} className="border-b border-border last:border-b-0">
|
||||
<div
|
||||
@@ -381,11 +382,15 @@ export function DiscardWorktreeChangesDialog({
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => handleToggleFile(file.path)}
|
||||
className="flex-shrink-0"
|
||||
aria-labelledby={fileButtonId}
|
||||
/>
|
||||
|
||||
{/* Clickable file row to show diff */}
|
||||
<button
|
||||
id={fileButtonId}
|
||||
type="button"
|
||||
onClick={() => handleFileClick(file.path)}
|
||||
aria-expanded={isExpanded}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
|
||||
@@ -5,7 +5,11 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon';
|
||||
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||
import {
|
||||
getExpectedWeeklyPacePercentage,
|
||||
getExpectedCodexPacePercentage,
|
||||
getPaceStatusLabel,
|
||||
} from '@/store/utils/usage-utils';
|
||||
|
||||
interface MobileUsageBarProps {
|
||||
showClaudeUsage: boolean;
|
||||
@@ -345,6 +349,10 @@ export function MobileUsageBar({
|
||||
label={getCodexWindowLabel(codexUsage.rateLimits.primary.windowDurationMins)}
|
||||
percentage={codexUsage.rateLimits.primary.usedPercent}
|
||||
isStale={isCodexStale}
|
||||
pacePercentage={getExpectedCodexPacePercentage(
|
||||
codexUsage.rateLimits.primary.resetsAt,
|
||||
codexUsage.rateLimits.primary.windowDurationMins
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{codexUsage.rateLimits.secondary && (
|
||||
@@ -352,6 +360,10 @@ export function MobileUsageBar({
|
||||
label={getCodexWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins)}
|
||||
percentage={codexUsage.rateLimits.secondary.usedPercent}
|
||||
isStale={isCodexStale}
|
||||
pacePercentage={getExpectedCodexPacePercentage(
|
||||
codexUsage.rateLimits.secondary.resetsAt,
|
||||
codexUsage.rateLimits.secondary.windowDurationMins
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useCodexUsage } from '@/hooks/queries';
|
||||
import type { CodexRateLimitWindow } from '@/store/app-store';
|
||||
import { getExpectedCodexPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||
|
||||
const CODEX_USAGE_TITLE = 'Codex Usage';
|
||||
const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
|
||||
@@ -73,6 +74,12 @@ export function CodexUsageSection() {
|
||||
}) => {
|
||||
const safePercentage = Math.min(Math.max(limitWindow.usedPercent, 0), MAX_PERCENTAGE);
|
||||
const resetLabel = formatCodexResetTime(limitWindow.resetsAt);
|
||||
const pacePercentage = getExpectedCodexPacePercentage(
|
||||
limitWindow.resetsAt,
|
||||
limitWindow.windowDurationMins
|
||||
);
|
||||
const paceLabel =
|
||||
pacePercentage != null ? getPaceStatusLabel(safePercentage, pacePercentage) : null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
|
||||
@@ -85,7 +92,7 @@ export function CodexUsageSection() {
|
||||
{Math.round(safePercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 w-full rounded-full bg-secondary/60">
|
||||
<div className="relative mt-3 h-2 w-full rounded-full bg-secondary/60">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
@@ -93,8 +100,29 @@ export function CodexUsageSection() {
|
||||
)}
|
||||
style={{ width: `${safePercentage}%` }}
|
||||
/>
|
||||
{pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && (
|
||||
<div
|
||||
className="absolute top-0 h-full w-0.5 bg-foreground/60"
|
||||
style={{ left: `${pacePercentage}%` }}
|
||||
title={`Expected: ${Math.round(pacePercentage)}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
{paceLabel ? (
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
safePercentage > (pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500'
|
||||
)}
|
||||
>
|
||||
{paceLabel}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{resetLabel && <p className="text-xs text-muted-foreground">{resetLabel}</p>}
|
||||
</div>
|
||||
{resetLabel && <p className="mt-2 text-xs text-muted-foreground">{resetLabel}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useOpencodeModels } from '@/hooks/queries';
|
||||
import type {
|
||||
ModelAlias,
|
||||
CursorModelId,
|
||||
@@ -180,14 +181,16 @@ export function PhaseModelSelector({
|
||||
codexModels,
|
||||
codexModelsLoading,
|
||||
fetchCodexModels,
|
||||
dynamicOpencodeModels,
|
||||
enabledDynamicModelIds,
|
||||
opencodeModelsLoading,
|
||||
fetchOpencodeModels,
|
||||
disabledProviders,
|
||||
claudeCompatibleProviders,
|
||||
} = useAppStore();
|
||||
|
||||
// Use React Query for OpenCode models so that changes made in the settings tab
|
||||
// (which also uses React Query) are immediately reflected here via the shared cache,
|
||||
// without requiring a page refresh.
|
||||
const { data: dynamicOpencodeModels = [] } = useOpencodeModels();
|
||||
|
||||
// Detect mobile devices to use inline expansion instead of nested popovers
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -211,14 +214,9 @@ export function PhaseModelSelector({
|
||||
}
|
||||
}, [codexModels.length, codexModelsLoading, fetchCodexModels]);
|
||||
|
||||
// Fetch OpenCode models on mount
|
||||
useEffect(() => {
|
||||
if (dynamicOpencodeModels.length === 0 && !opencodeModelsLoading) {
|
||||
fetchOpencodeModels().catch(() => {
|
||||
// Silently fail - user will see only static OpenCode models
|
||||
});
|
||||
}
|
||||
}, [dynamicOpencodeModels.length, opencodeModelsLoading, fetchOpencodeModels]);
|
||||
// OpenCode dynamic models are now fetched via React Query (useOpencodeModels above),
|
||||
// which shares a cache with the settings tab. This ensures that newly enabled models
|
||||
// appear in the selector immediately after the settings tab fetches/invalidates the data.
|
||||
|
||||
// Close expanded group when trigger scrolls out of view
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -20,6 +20,7 @@ export function OpencodeSettingsTab() {
|
||||
toggleOpencodeModel,
|
||||
enabledDynamicModelIds,
|
||||
toggleDynamicModel,
|
||||
setDynamicOpencodeModels,
|
||||
} = useAppStore();
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -37,6 +38,16 @@ export function OpencodeSettingsTab() {
|
||||
|
||||
const { data: modelsData = [], isFetching: isFetchingModels } = useOpencodeModels();
|
||||
|
||||
// Sync React Query opencode models data to Zustand store so that the model
|
||||
// selector dropdown (PhaseModelSelector) reflects newly enabled models without
|
||||
// requiring a page refresh. The selector reads from the Zustand store while
|
||||
// this settings tab fetches via React Query — keeping them in sync bridges that gap.
|
||||
useEffect(() => {
|
||||
if (modelsData.length > 0) {
|
||||
setDynamicOpencodeModels(modelsData);
|
||||
}
|
||||
}, [modelsData, setDynamicOpencodeModels]);
|
||||
|
||||
// Transform CLI status to the expected format
|
||||
const cliStatus = useMemo((): SharedCliStatus | null => {
|
||||
if (!cliStatusData) return null;
|
||||
|
||||
Reference in New Issue
Block a user