mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Fix orphaned features when deleting worktrees (#820)
* Changes from fix/orphaned-features * fix: Handle feature migration failures and improve UI accessibility * feat: Add event emission for worktree deletion and feature migration * fix: Handle OpenCode model errors and prevent duplicate model IDs * feat: Add summary dialog and async verify with loading state * fix: Add type attributes to buttons and improve OpenCode model selection * fix: Add null checks for onVerify callback and opencode model selection
This commit is contained in:
@@ -493,7 +493,7 @@ app.use(
|
||||
);
|
||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService, featureLoader));
|
||||
app.use('/api/git', createGitRoutes());
|
||||
app.use('/api/models', createModelsRoutes());
|
||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||
|
||||
@@ -1189,8 +1189,26 @@ export class OpencodeProvider extends CliProvider {
|
||||
* Format a display name for a model
|
||||
*/
|
||||
private formatModelDisplayName(model: OpenCodeModelInfo): string {
|
||||
// Extract the last path segment for nested model IDs
|
||||
// e.g., "arcee-ai/trinity-large-preview:free" → "trinity-large-preview:free"
|
||||
let rawName = model.name;
|
||||
if (rawName.includes('/')) {
|
||||
rawName = rawName.split('/').pop()!;
|
||||
}
|
||||
|
||||
// Strip tier/pricing suffixes like ":free", ":extended"
|
||||
const colonIdx = rawName.indexOf(':');
|
||||
let suffix = '';
|
||||
if (colonIdx !== -1) {
|
||||
const tierPart = rawName.slice(colonIdx + 1);
|
||||
if (/^(free|extended|beta|preview)$/i.test(tierPart)) {
|
||||
suffix = ` (${tierPart.charAt(0).toUpperCase() + tierPart.slice(1)})`;
|
||||
}
|
||||
rawName = rawName.slice(0, colonIdx);
|
||||
}
|
||||
|
||||
// Capitalize and format the model name
|
||||
const formattedName = model.name
|
||||
const formattedName = rawName
|
||||
.split('-')
|
||||
.map((part) => {
|
||||
// Handle version numbers like "4-5" -> "4.5"
|
||||
@@ -1218,7 +1236,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
};
|
||||
|
||||
const providerDisplay = providerNames[model.provider] || model.provider;
|
||||
return `${formattedName} (${providerDisplay})`;
|
||||
return `${formattedName}${suffix} (${providerDisplay})`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,10 +71,12 @@ import { createSetTrackingHandler } from './routes/set-tracking.js';
|
||||
import { createSyncHandler } from './routes/sync.js';
|
||||
import { createUpdatePRNumberHandler } from './routes/update-pr-number.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { FeatureLoader } from '../../services/feature-loader.js';
|
||||
|
||||
export function createWorktreeRoutes(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
settingsService?: SettingsService,
|
||||
featureLoader?: FeatureLoader
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -94,7 +96,11 @@ export function createWorktreeRoutes(
|
||||
validatePathParams('projectPath'),
|
||||
createCreateHandler(events, settingsService)
|
||||
);
|
||||
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
||||
router.post(
|
||||
'/delete',
|
||||
validatePathParams('projectPath', 'worktreePath'),
|
||||
createDeleteHandler(events, featureLoader)
|
||||
);
|
||||
router.post('/create-pr', createCreatePRHandler());
|
||||
router.post('/pr-info', createPRInfoHandler());
|
||||
router.post(
|
||||
|
||||
@@ -10,11 +10,13 @@ import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||
import { execGitCommand } from '../../../lib/git.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('Worktree');
|
||||
|
||||
export function createDeleteHandler() {
|
||||
export function createDeleteHandler(events: EventEmitter, featureLoader?: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, worktreePath, deleteBranch } = req.body as {
|
||||
@@ -134,12 +136,65 @@ export function createDeleteHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit worktree:deleted event after successful deletion
|
||||
events.emit('worktree:deleted', {
|
||||
worktreePath,
|
||||
projectPath,
|
||||
branchName,
|
||||
branchDeleted,
|
||||
});
|
||||
|
||||
// Move features associated with the deleted branch to the main worktree
|
||||
// This prevents features from being orphaned when a worktree is deleted
|
||||
let featuresMovedToMain = 0;
|
||||
if (featureLoader && branchName) {
|
||||
try {
|
||||
const allFeatures = await featureLoader.getAll(projectPath);
|
||||
const affectedFeatures = allFeatures.filter((f) => f.branchName === branchName);
|
||||
for (const feature of affectedFeatures) {
|
||||
try {
|
||||
await featureLoader.update(projectPath, feature.id, {
|
||||
branchName: null,
|
||||
});
|
||||
featuresMovedToMain++;
|
||||
// Emit feature:migrated event for each successfully migrated feature
|
||||
events.emit('feature:migrated', {
|
||||
featureId: feature.id,
|
||||
status: 'migrated',
|
||||
fromBranch: branchName,
|
||||
toWorktreeId: null, // migrated to main worktree (no specific worktree)
|
||||
projectPath,
|
||||
});
|
||||
} catch (featureUpdateError) {
|
||||
// Non-fatal: log per-feature failure but continue migrating others
|
||||
logger.warn('Failed to move feature to main worktree after deletion', {
|
||||
error: getErrorMessage(featureUpdateError),
|
||||
featureId: feature.id,
|
||||
branchName,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (featuresMovedToMain > 0) {
|
||||
logger.info(
|
||||
`Moved ${featuresMovedToMain} feature(s) to main worktree after deleting worktree with branch: ${branchName}`
|
||||
);
|
||||
}
|
||||
} catch (featureError) {
|
||||
// Non-fatal: log but don't fail the deletion (getAll failed)
|
||||
logger.warn('Failed to load features for migration to main worktree after deletion', {
|
||||
error: getErrorMessage(featureError),
|
||||
branchName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deleted: {
|
||||
worktreePath,
|
||||
branch: branchDeleted ? branchName : null,
|
||||
branchDeleted,
|
||||
featuresMovedToMain,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -378,6 +378,7 @@ export class FeatureLoader {
|
||||
description: featureData.description || '',
|
||||
...featureData,
|
||||
id: featureId,
|
||||
createdAt: featureData.createdAt || new Date().toISOString(),
|
||||
imagePaths: migratedImagePaths,
|
||||
descriptionHistory: initialHistory,
|
||||
};
|
||||
|
||||
@@ -312,7 +312,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
|
||||
<SheetHeader className="px-6 pt-6">
|
||||
<SheetHeader
|
||||
className="px-6"
|
||||
style={{
|
||||
paddingTop: 'max(1.5rem, calc(env(safe-area-inset-top, 0px) + 1rem))',
|
||||
}}
|
||||
>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<ImageIcon className="w-5 h-5 text-brand-500" />
|
||||
Board Background Settings
|
||||
|
||||
@@ -57,6 +57,8 @@ const SheetContent = ({ className, children, side = 'right', ...props }: SheetCo
|
||||
const Close = SheetPrimitive.Close as React.ComponentType<{
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
'data-slot'?: string;
|
||||
style?: React.CSSProperties;
|
||||
}>;
|
||||
|
||||
return (
|
||||
@@ -79,7 +81,13 @@ const SheetContent = ({ className, children, side = 'right', ...props }: SheetCo
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<Close
|
||||
data-slot="sheet-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||
style={{
|
||||
top: 'max(1rem, calc(env(safe-area-inset-top, 0px) + 0.5rem))',
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Close>
|
||||
|
||||
@@ -27,22 +27,22 @@ export function AgentHeader({
|
||||
worktreeBranch,
|
||||
}: AgentHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-3 sm:px-6 sm:py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 sm:gap-4 min-w-0">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<span className="truncate">
|
||||
{projectName}
|
||||
{currentSessionId && !isConnected && ' - Connecting...'}
|
||||
</span>
|
||||
{worktreeBranch && (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted/50 px-2 py-0.5 rounded-full border border-border">
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted/50 px-2 py-0.5 rounded-full border border-border shrink-0">
|
||||
<GitBranch className="w-3 h-3 shrink-0" />
|
||||
<span className="max-w-[180px] truncate">{worktreeBranch}</span>
|
||||
<span className="max-w-[100px] sm:max-w-[180px] truncate">{worktreeBranch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -50,9 +50,9 @@ export function AgentHeader({
|
||||
</div>
|
||||
|
||||
{/* Status indicators & actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1 sm:gap-3 shrink-0">
|
||||
{currentTool && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
|
||||
<div className="hidden sm:flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
|
||||
<Wrench className="w-3 h-3 text-primary" />
|
||||
<span className="font-medium">{currentTool}</span>
|
||||
</div>
|
||||
@@ -63,10 +63,11 @@ export function AgentHeader({
|
||||
size="sm"
|
||||
onClick={onClearChat}
|
||||
disabled={isProcessing}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear chat"
|
||||
className="text-muted-foreground hover:text-foreground h-8 w-8 p-0 sm:w-auto sm:px-3"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear
|
||||
<Trash2 className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -133,6 +133,7 @@ export function BoardView() {
|
||||
getPrimaryWorktreeBranch,
|
||||
setPipelineConfig,
|
||||
featureTemplates,
|
||||
defaultSortNewestCardOnTop,
|
||||
} = useAppStore(
|
||||
useShallow((state) => ({
|
||||
currentProject: state.currentProject,
|
||||
@@ -152,6 +153,7 @@ export function BoardView() {
|
||||
getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch,
|
||||
setPipelineConfig: state.setPipelineConfig,
|
||||
featureTemplates: state.featureTemplates,
|
||||
defaultSortNewestCardOnTop: state.defaultSortNewestCardOnTop,
|
||||
}))
|
||||
);
|
||||
// Also get keyboard shortcuts for the add feature shortcut
|
||||
@@ -1458,6 +1460,11 @@ export function BoardView() {
|
||||
]
|
||||
);
|
||||
|
||||
// Use background hook for visual settings (background image, opacity, etc.)
|
||||
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
|
||||
currentProject,
|
||||
});
|
||||
|
||||
// Use column features hook
|
||||
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
|
||||
features: hookFeatures,
|
||||
@@ -1467,6 +1474,7 @@ export function BoardView() {
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
projectPath: currentProject?.path || null,
|
||||
sortNewestCardOnTop: defaultSortNewestCardOnTop,
|
||||
});
|
||||
|
||||
// Build columnFeaturesMap for ListView
|
||||
@@ -1480,11 +1488,6 @@ export function BoardView() {
|
||||
return map;
|
||||
}, [pipelineConfig, getColumnFeatures]);
|
||||
|
||||
// Use background hook
|
||||
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
|
||||
currentProject,
|
||||
});
|
||||
|
||||
// Find feature for pending plan approval
|
||||
const pendingApprovalFeature = useMemo(() => {
|
||||
if (!pendingPlanApproval) return null;
|
||||
@@ -1802,6 +1805,7 @@ export function BoardView() {
|
||||
handleViewOutput(feature);
|
||||
}
|
||||
}}
|
||||
sortNewestCardOnTop={defaultSortNewestCardOnTop}
|
||||
className="transition-opacity duration-200"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -458,6 +458,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
)}
|
||||
{effectiveTodos.length > 3 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsTodosExpanded(!isTodosExpanded);
|
||||
@@ -481,11 +482,22 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
{effectiveSummary && (
|
||||
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSummaryDialogOpen(true);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
title="View full summary"
|
||||
>
|
||||
<Sparkles className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate font-medium">Summary</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSummaryDialogOpen(true);
|
||||
|
||||
@@ -3,7 +3,15 @@ import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AlertCircle, AlertTriangle, Lock, Hand, Sparkles, SkipForward } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Lock,
|
||||
Hand,
|
||||
Sparkles,
|
||||
SkipForward,
|
||||
FileCheck,
|
||||
} from 'lucide-react';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
|
||||
@@ -147,12 +155,15 @@ export const PriorityBadges = memo(function PriorityBadges({
|
||||
excludedStepCount > 0 && totalPipelineSteps > 0 && feature.status === 'backlog';
|
||||
const allPipelinesExcluded = hasPipelineExclusions && excludedStepCount >= totalPipelineSteps;
|
||||
|
||||
const showPlanApproval = feature.planSpec?.status === 'generated';
|
||||
|
||||
const showBadges =
|
||||
feature.priority ||
|
||||
showManualVerification ||
|
||||
isBlocked ||
|
||||
isJustFinished ||
|
||||
hasPipelineExclusions;
|
||||
hasPipelineExclusions ||
|
||||
showPlanApproval;
|
||||
|
||||
if (!showBadges) {
|
||||
return null;
|
||||
@@ -264,6 +275,26 @@ export const PriorityBadges = memo(function PriorityBadges({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Plan approval badge */}
|
||||
{showPlanApproval && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-purple-500/20 border-purple-500/50 text-purple-500 animate-pulse'
|
||||
)}
|
||||
data-testid={`plan-approval-badge-${feature.id}`}
|
||||
>
|
||||
<FileCheck className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p>Plan ready for review - click or tap to approve</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Pipeline exclusion badge */}
|
||||
{hasPipelineExclusions && (
|
||||
<Tooltip>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { memo, useCallback, useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react';
|
||||
import { AlertCircle, Lock, Hand, Sparkles, FileText, FileCheck } from 'lucide-react';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
import { RowActions, type RowActionHandlers } from './row-actions';
|
||||
import { getColumnWidth, getColumnAlign } from './list-header';
|
||||
@@ -120,7 +120,17 @@ const IndicatorBadges = memo(function IndicatorBadges({
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPlan) {
|
||||
if (feature.planSpec?.status === 'generated') {
|
||||
badges.push({
|
||||
key: 'plan-approval',
|
||||
icon: FileCheck,
|
||||
tooltip: 'Plan ready for review - tap to approve',
|
||||
colorClass: 'text-purple-500',
|
||||
bgClass: 'bg-purple-500/15',
|
||||
borderClass: 'border-purple-500/30',
|
||||
animate: true,
|
||||
});
|
||||
} else if (hasPlan) {
|
||||
badges.push({
|
||||
key: 'plan',
|
||||
icon: FileText,
|
||||
@@ -400,8 +410,13 @@ export function getFeatureSortValue(
|
||||
return (feature.category || '').toLowerCase();
|
||||
case 'priority':
|
||||
return feature.priority || 999; // No priority sorts last
|
||||
case 'createdAt':
|
||||
return feature.createdAt ? new Date(feature.createdAt) : new Date(0);
|
||||
case 'createdAt': {
|
||||
if (feature.createdAt) return new Date(feature.createdAt);
|
||||
// Fallback: extract timestamp from feature ID (e.g., "feature-1772299989679-185nwyp5kc7")
|
||||
const match = feature.id.match(/^feature-(\d+)-/);
|
||||
if (match) return new Date(parseInt(match[1], 10));
|
||||
return new Date(0);
|
||||
}
|
||||
case 'updatedAt':
|
||||
return feature.updatedAt ? new Date(feature.updatedAt) : new Date(0);
|
||||
default:
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface ListViewProps {
|
||||
sortConfig: SortConfig;
|
||||
/** Callback when sort column is changed */
|
||||
onSortChange: (column: SortColumn) => void;
|
||||
/** When true, always sort by most recent (createdAt desc), overriding the current sort config */
|
||||
sortNewestCardOnTop?: boolean;
|
||||
/** Action handlers for rows */
|
||||
actionHandlers: ListViewActionHandlers;
|
||||
/** Set of feature IDs that are currently running */
|
||||
@@ -229,6 +231,7 @@ export const ListView = memo(function ListView({
|
||||
onToggleFeatureSelection,
|
||||
onRowClick,
|
||||
className,
|
||||
sortNewestCardOnTop = false,
|
||||
}: ListViewProps) {
|
||||
// Track collapsed state for each status group
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
@@ -239,14 +242,23 @@ export const ListView = memo(function ListView({
|
||||
|
||||
// Generate status groups from columnFeaturesMap
|
||||
const statusGroups = useMemo<StatusGroup[]>(() => {
|
||||
// Effective sort config: when sortNewestCardOnTop is enabled, sort by createdAt desc
|
||||
const effectiveSortConfig: SortConfig = sortNewestCardOnTop
|
||||
? { column: 'createdAt', direction: 'desc' }
|
||||
: sortConfig;
|
||||
|
||||
const columns = getColumnsWithPipeline(pipelineConfig);
|
||||
const groups: StatusGroup[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
const features = columnFeaturesMap[column.id] || [];
|
||||
if (features.length > 0) {
|
||||
// Sort features within the group according to current sort config
|
||||
const sortedFeatures = sortFeatures(features, sortConfig.column, sortConfig.direction);
|
||||
// Sort features within the group according to effective sort config
|
||||
const sortedFeatures = sortFeatures(
|
||||
features,
|
||||
effectiveSortConfig.column,
|
||||
effectiveSortConfig.direction
|
||||
);
|
||||
|
||||
groups.push({
|
||||
id: column.id as FeatureStatusWithPipeline,
|
||||
@@ -259,7 +271,7 @@ export const ListView = memo(function ListView({
|
||||
|
||||
// Sort groups by status order
|
||||
return groups.sort((a, b) => getStatusOrder(a.id) - getStatusOrder(b.id));
|
||||
}, [columnFeaturesMap, pipelineConfig, sortConfig]);
|
||||
}, [columnFeaturesMap, pipelineConfig, sortNewestCardOnTop, sortConfig]);
|
||||
|
||||
// Calculate total feature count
|
||||
const totalFeatures = useMemo(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pencil, X, CheckSquare, Trash2, CheckCircle2 } from 'lucide-react';
|
||||
import { Pencil, X, CheckSquare, Trash2, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,7 +18,7 @@ interface SelectionActionBarProps {
|
||||
totalCount: number;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onVerify?: () => void;
|
||||
onVerify?: () => Promise<void> | void;
|
||||
onClear: () => void;
|
||||
onSelectAll: () => void;
|
||||
mode?: SelectionActionMode;
|
||||
@@ -36,6 +36,7 @@ export function SelectionActionBar({
|
||||
}: SelectionActionBarProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showVerifyDialog, setShowVerifyDialog] = useState(false);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
const allSelected = selectedCount === totalCount && totalCount > 0;
|
||||
|
||||
@@ -49,12 +50,22 @@ export function SelectionActionBar({
|
||||
};
|
||||
|
||||
const handleVerifyClick = () => {
|
||||
if (!onVerify) return;
|
||||
setShowVerifyDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmVerify = () => {
|
||||
const handleConfirmVerify = async () => {
|
||||
if (!onVerify) {
|
||||
setShowVerifyDialog(false);
|
||||
onVerify?.();
|
||||
return;
|
||||
}
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
await onVerify();
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
setShowVerifyDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -112,7 +123,7 @@ export function SelectionActionBar({
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleVerifyClick}
|
||||
disabled={selectedCount === 0}
|
||||
disabled={selectedCount === 0 || !onVerify}
|
||||
className="h-8 bg-green-600 hover:bg-green-700 disabled:opacity-50"
|
||||
data-testid="selection-verify-button"
|
||||
>
|
||||
@@ -184,7 +195,12 @@ export function SelectionActionBar({
|
||||
</Dialog>
|
||||
|
||||
{/* Verify Confirmation Dialog */}
|
||||
<Dialog open={showVerifyDialog} onOpenChange={setShowVerifyDialog}>
|
||||
<Dialog
|
||||
open={showVerifyDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!isVerifying) setShowVerifyDialog(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent data-testid="bulk-verify-confirmation-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-green-600">
|
||||
@@ -203,6 +219,7 @@ export function SelectionActionBar({
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowVerifyDialog(false)}
|
||||
disabled={isVerifying}
|
||||
data-testid="cancel-bulk-verify-button"
|
||||
>
|
||||
Cancel
|
||||
@@ -210,10 +227,15 @@ export function SelectionActionBar({
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={handleConfirmVerify}
|
||||
disabled={isVerifying || !onVerify}
|
||||
data-testid="confirm-bulk-verify-button"
|
||||
>
|
||||
{isVerifying ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Verify
|
||||
)}
|
||||
{isVerifying ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -85,7 +85,10 @@ export function PlanApprovalDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-4xl" data-testid="plan-approval-dialog">
|
||||
<DialogContent
|
||||
className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile"
|
||||
data-testid="plan-approval-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{viewOnly ? 'View Plan' : 'Review Plan'}
|
||||
@@ -146,12 +149,12 @@ export function PlanApprovalDialog({
|
||||
)}
|
||||
|
||||
{/* Plan Content */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[70vh] border border-border rounded-lg">
|
||||
<div className="flex-1 overflow-y-auto sm:max-h-[60vh] border border-border rounded-lg">
|
||||
{isEditMode && !viewOnly ? (
|
||||
<Textarea
|
||||
value={editedPlan}
|
||||
onChange={(e) => setEditedPlan(e.target.value)}
|
||||
className="min-h-[400px] h-full w-full border-0 rounded-lg resize-none font-mono text-sm"
|
||||
className="min-h-[200px] sm:min-h-[400px] h-full w-full border-0 rounded-lg resize-none font-mono text-sm"
|
||||
placeholder="Enter plan content..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -179,17 +182,31 @@ export function PlanApprovalDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0 gap-2">
|
||||
<DialogFooter className="flex-shrink-0 gap-2 flex-col sm:flex-row">
|
||||
{viewOnly ? (
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
) : showRejectFeedback ? (
|
||||
<>
|
||||
<Button variant="ghost" onClick={handleCancelReject} disabled={isLoading}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCancelReject}
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleReject} disabled={isLoading}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleReject}
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
@@ -200,21 +217,26 @@ export function PlanApprovalDialog({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleReject} disabled={isLoading}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReject}
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto order-2 sm:order-1"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Request Changes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={isLoading}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
className="bg-green-600 hover:bg-green-700 text-white w-full sm:w-auto order-1 sm:order-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Approve
|
||||
Approve Plan
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -268,6 +268,7 @@ export function useBoardActions({
|
||||
status: initialStatus,
|
||||
branchName: finalBranchName,
|
||||
dependencies: featureData.dependencies || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
...(initialStatus === 'in_progress' ? { startedAt: new Date().toISOString() } : {}),
|
||||
};
|
||||
const createdFeature = addFeature(newFeatureData);
|
||||
|
||||
@@ -11,9 +11,10 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
|
||||
|
||||
// Get background settings for current project
|
||||
const backgroundSettings = useMemo(() => {
|
||||
return (
|
||||
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings
|
||||
);
|
||||
const perProjectSettings = currentProject
|
||||
? boardBackgroundByProject[currentProject.path]
|
||||
: null;
|
||||
return perProjectSettings || defaultBackgroundSettings;
|
||||
}, [currentProject, boardBackgroundByProject]);
|
||||
|
||||
// Build background image style if image exists
|
||||
|
||||
@@ -9,6 +9,147 @@ import {
|
||||
|
||||
type ColumnId = Feature['status'];
|
||||
|
||||
/**
|
||||
* Extract creation time from a feature, falling back to the timestamp
|
||||
* embedded in the feature ID (format: feature-{timestamp}-{random}).
|
||||
*/
|
||||
function getFeatureCreatedTime(feature: Feature): number {
|
||||
if (feature.createdAt) {
|
||||
return new Date(feature.createdAt).getTime();
|
||||
}
|
||||
// Fallback: extract timestamp from feature ID (e.g., "feature-1772299989679-185nwyp5kc7")
|
||||
const match = feature.id.match(/^feature-(\d+)-/);
|
||||
if (match) {
|
||||
return parseInt(match[1], 10);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort features newest-first while respecting dependency ordering.
|
||||
*
|
||||
* Groups features into dependency chains and sorts the chains by the newest
|
||||
* feature in each chain (descending). Within each chain, dependencies appear
|
||||
* before their dependents (topological order preserved).
|
||||
*
|
||||
* Features without any dependency relationships are treated as single-item chains
|
||||
* and sorted by their own creation time.
|
||||
*/
|
||||
function sortNewestWithDependencies(features: Feature[]): Feature[] {
|
||||
if (features.length <= 1) return features;
|
||||
|
||||
const featureMap = new Map(features.map((f) => [f.id, f]));
|
||||
const featureSet = new Set(features.map((f) => f.id));
|
||||
|
||||
// Build adjacency: parent -> children (dependency -> dependents) scoped to this list
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
const parentOf = new Map<string, string[]>();
|
||||
for (const f of features) {
|
||||
childrenOf.set(f.id, []);
|
||||
parentOf.set(f.id, []);
|
||||
}
|
||||
for (const f of features) {
|
||||
for (const depId of f.dependencies || []) {
|
||||
if (featureSet.has(depId)) {
|
||||
childrenOf.get(depId)!.push(f.id);
|
||||
parentOf.get(f.id)!.push(depId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find connected components (dependency chains/groups)
|
||||
const visited = new Set<string>();
|
||||
const components: string[][] = [];
|
||||
|
||||
function collectComponent(startId: string): string[] {
|
||||
const component: string[] = [];
|
||||
const stack = [startId];
|
||||
while (stack.length > 0) {
|
||||
const id = stack.pop()!;
|
||||
if (visited.has(id)) continue;
|
||||
visited.add(id);
|
||||
component.push(id);
|
||||
// Traverse both directions to find full connected component
|
||||
for (const childId of childrenOf.get(id) || []) {
|
||||
if (!visited.has(childId)) stack.push(childId);
|
||||
}
|
||||
for (const pid of parentOf.get(id) || []) {
|
||||
if (!visited.has(pid)) stack.push(pid);
|
||||
}
|
||||
}
|
||||
return component;
|
||||
}
|
||||
|
||||
for (const f of features) {
|
||||
if (!visited.has(f.id)) {
|
||||
components.push(collectComponent(f.id));
|
||||
}
|
||||
}
|
||||
|
||||
// For each component, find the newest feature time (used to sort components)
|
||||
// and produce a topological ordering within the component
|
||||
const sortedComponents: { newestTime: number; ordered: Feature[] }[] = [];
|
||||
|
||||
for (const component of components) {
|
||||
let newestTime = 0;
|
||||
for (const id of component) {
|
||||
const t = getFeatureCreatedTime(featureMap.get(id)!);
|
||||
if (t > newestTime) newestTime = t;
|
||||
}
|
||||
|
||||
// Topological sort within component (dependencies first)
|
||||
// Use the existing order from `features` as a stable fallback
|
||||
const componentSet = new Set(component);
|
||||
const inDegree = new Map<string, number>();
|
||||
for (const id of component) {
|
||||
let deg = 0;
|
||||
for (const pid of parentOf.get(id) || []) {
|
||||
if (componentSet.has(pid)) deg++;
|
||||
}
|
||||
inDegree.set(id, deg);
|
||||
}
|
||||
|
||||
const queue: Feature[] = [];
|
||||
for (const id of component) {
|
||||
if (inDegree.get(id) === 0) {
|
||||
queue.push(featureMap.get(id)!);
|
||||
}
|
||||
}
|
||||
// Within same level, sort newest first
|
||||
queue.sort((a, b) => getFeatureCreatedTime(b) - getFeatureCreatedTime(a));
|
||||
|
||||
const ordered: Feature[] = [];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
ordered.push(current);
|
||||
for (const childId of childrenOf.get(current.id) || []) {
|
||||
if (!componentSet.has(childId)) continue;
|
||||
const newDeg = (inDegree.get(childId) || 1) - 1;
|
||||
inDegree.set(childId, newDeg);
|
||||
if (newDeg === 0) {
|
||||
queue.push(featureMap.get(childId)!);
|
||||
queue.sort((a, b) => getFeatureCreatedTime(b) - getFeatureCreatedTime(a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append any remaining (circular deps) at end
|
||||
for (const id of component) {
|
||||
if (!ordered.some((f) => f.id === id)) {
|
||||
ordered.push(featureMap.get(id)!);
|
||||
}
|
||||
}
|
||||
|
||||
sortedComponents.push({ newestTime, ordered });
|
||||
}
|
||||
|
||||
// Sort components by newest feature time (descending)
|
||||
sortedComponents.sort((a, b) => b.newestTime - a.newestTime);
|
||||
|
||||
// Flatten: each component's internal order is preserved
|
||||
return sortedComponents.flatMap((c) => c.ordered);
|
||||
}
|
||||
|
||||
interface UseBoardColumnFeaturesProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
@@ -17,6 +158,7 @@ interface UseBoardColumnFeaturesProps {
|
||||
currentWorktreePath: string | null; // Currently selected worktree path
|
||||
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
|
||||
projectPath: string | null; // Main project path (for main worktree)
|
||||
sortNewestCardOnTop?: boolean; // When true, sort cards by most recent (createdAt desc) in all columns
|
||||
}
|
||||
|
||||
export function useBoardColumnFeatures({
|
||||
@@ -27,6 +169,7 @@ export function useBoardColumnFeatures({
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
projectPath,
|
||||
sortNewestCardOnTop = false,
|
||||
}: UseBoardColumnFeaturesProps) {
|
||||
// Get recently completed features from store for race condition protection
|
||||
const recentlyCompletedFeatures = useAppStore((state) => state.recentlyCompletedFeatures);
|
||||
@@ -273,11 +416,36 @@ export function useBoardColumnFeatures({
|
||||
}
|
||||
}
|
||||
|
||||
if (sortNewestCardOnTop) {
|
||||
// Sort each group newest-first while keeping dependency chains nested
|
||||
map.backlog = [
|
||||
...sortNewestWithDependencies(unblocked),
|
||||
...sortNewestWithDependencies(blocked),
|
||||
];
|
||||
} else {
|
||||
map.backlog = [...unblocked, ...blocked];
|
||||
}
|
||||
} else {
|
||||
if (sortNewestCardOnTop) {
|
||||
map.backlog = sortNewestWithDependencies(orderedFeatures);
|
||||
} else {
|
||||
map.backlog = orderedFeatures;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply newest-on-top sorting to non-backlog columns when enabled
|
||||
// (Backlog is handled above with dependency-aware sorting)
|
||||
if (sortNewestCardOnTop) {
|
||||
for (const columnId of Object.keys(map)) {
|
||||
if (columnId === 'backlog') continue;
|
||||
map[columnId] = [...map[columnId]].sort((a, b) => {
|
||||
const aTime = getFeatureCreatedTime(a);
|
||||
const bTime = getFeatureCreatedTime(b);
|
||||
return bTime - aTime; // desc: newest first
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [
|
||||
@@ -289,6 +457,7 @@ export function useBoardColumnFeatures({
|
||||
currentWorktreeBranch,
|
||||
projectPath,
|
||||
recentlyCompletedFeatures,
|
||||
sortNewestCardOnTop,
|
||||
]);
|
||||
|
||||
const getColumnFeatures = useCallback(
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Brain, AlertTriangle } from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { getModelProvider } from '@automaker/types';
|
||||
import type { ModelProvider, CursorModelId } from '@automaker/types';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
|
||||
import { useEffect } from 'react';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, OPENCODE_MODELS, ModelOption } from './model-constants';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useOpencodeModels } from '@/hooks/queries';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
|
||||
@@ -30,9 +31,22 @@ export function ModelSelector({
|
||||
codexModelsError,
|
||||
fetchCodexModels,
|
||||
disabledProviders,
|
||||
enabledOpencodeModels,
|
||||
opencodeDefaultModel,
|
||||
enabledDynamicModelIds,
|
||||
opencodeModelsLoading,
|
||||
fetchOpencodeModels,
|
||||
} = useAppStore();
|
||||
const { cursorCliStatus, codexCliStatus } = useSetupStore();
|
||||
|
||||
// Use React Query for OpenCode models so changes in settings are reflected immediately
|
||||
const {
|
||||
data: dynamicOpencodeModelsList = [],
|
||||
isLoading: dynamicOpencodeLoading,
|
||||
error: dynamicOpencodeError,
|
||||
refetch: refetchOpencodeModels,
|
||||
} = useOpencodeModels();
|
||||
|
||||
const selectedProvider = getModelProvider(selectedModel);
|
||||
|
||||
// Check if Cursor CLI is available
|
||||
@@ -49,6 +63,30 @@ export function ModelSelector({
|
||||
}
|
||||
}, [isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels]);
|
||||
|
||||
// Track whether we've already attempted to fetch OpenCode models to avoid repeated retries
|
||||
const opencodeFetchTriedRef = useRef(false);
|
||||
|
||||
// Fetch OpenCode models on mount if not already loaded (only once per mount)
|
||||
const isOpencodeEnabled = !disabledProviders.includes('opencode');
|
||||
useEffect(() => {
|
||||
if (
|
||||
isOpencodeEnabled &&
|
||||
!opencodeModelsLoading &&
|
||||
!dynamicOpencodeLoading &&
|
||||
dynamicOpencodeModelsList.length === 0 &&
|
||||
!opencodeFetchTriedRef.current
|
||||
) {
|
||||
opencodeFetchTriedRef.current = true;
|
||||
fetchOpencodeModels();
|
||||
}
|
||||
}, [
|
||||
isOpencodeEnabled,
|
||||
opencodeModelsLoading,
|
||||
dynamicOpencodeLoading,
|
||||
dynamicOpencodeModelsList.length,
|
||||
fetchOpencodeModels,
|
||||
]);
|
||||
|
||||
// Transform codex models from store to ModelOption format
|
||||
const dynamicCodexModels: ModelOption[] = codexModels.map((model) => {
|
||||
// Infer badge based on tier
|
||||
@@ -67,6 +105,36 @@ export function ModelSelector({
|
||||
};
|
||||
});
|
||||
|
||||
// Filter static OpenCode models based on enabled models from global settings
|
||||
const filteredStaticOpencodeModels = OPENCODE_MODELS.filter((model) =>
|
||||
(enabledOpencodeModels as string[]).includes(model.id)
|
||||
);
|
||||
|
||||
// Filter dynamic OpenCode models based on enabled dynamic model IDs
|
||||
const filteredDynamicOpencodeModels: ModelOption[] = dynamicOpencodeModelsList
|
||||
.filter((model) => enabledDynamicModelIds.includes(model.id))
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
description: model.description,
|
||||
provider: 'opencode' as ModelProvider,
|
||||
}));
|
||||
|
||||
// Combined OpenCode models (static + dynamic), deduplicating by model name
|
||||
// Static IDs use dash format (opencode-glm-5-free), dynamic use slash format (opencode/glm-5-free)
|
||||
const normalizeModelName = (id: string): string => {
|
||||
if (id.startsWith('opencode-')) return id.slice('opencode-'.length);
|
||||
if (id.startsWith('opencode/')) return id.slice('opencode/'.length);
|
||||
return id;
|
||||
};
|
||||
const staticModelNames = new Set(
|
||||
filteredStaticOpencodeModels.map((m) => normalizeModelName(m.id))
|
||||
);
|
||||
const uniqueDynamicModels = filteredDynamicOpencodeModels.filter(
|
||||
(m) => !staticModelNames.has(normalizeModelName(m.id))
|
||||
);
|
||||
const allOpencodeModels = [...filteredStaticOpencodeModels, ...uniqueDynamicModels];
|
||||
|
||||
// Filter Cursor models based on enabled models from global settings
|
||||
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
// enabledCursorModels stores CursorModelIds which may or may not have "cursor-" prefix
|
||||
@@ -93,6 +161,16 @@ export function ModelSelector({
|
||||
} else if (provider === 'claude' && selectedProvider !== 'claude') {
|
||||
// Switch to Claude's default model (canonical format)
|
||||
onModelSelect('claude-sonnet');
|
||||
} else if (provider === 'opencode' && selectedProvider !== 'opencode') {
|
||||
// Switch to OpenCode's default model (prefer configured default if it is actually enabled)
|
||||
const isDefaultModelAvailable =
|
||||
opencodeDefaultModel && allOpencodeModels.some((m) => m.id === opencodeDefaultModel);
|
||||
const defaultModelId = isDefaultModelAvailable
|
||||
? opencodeDefaultModel
|
||||
: allOpencodeModels[0]?.id;
|
||||
if (defaultModelId) {
|
||||
onModelSelect(defaultModelId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,12 +178,14 @@ export function ModelSelector({
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||
const isCodexDisabled = disabledProviders.includes('codex');
|
||||
const isOpencodeDisabled = disabledProviders.includes('opencode');
|
||||
|
||||
// Count available providers
|
||||
const availableProviders = [
|
||||
!isClaudeDisabled && 'claude',
|
||||
!isCursorDisabled && 'cursor',
|
||||
!isCodexDisabled && 'codex',
|
||||
!isOpencodeDisabled && 'opencode',
|
||||
].filter(Boolean) as ModelProvider[];
|
||||
|
||||
return (
|
||||
@@ -163,6 +243,22 @@ export function ModelSelector({
|
||||
Codex CLI
|
||||
</button>
|
||||
)}
|
||||
{!isOpencodeDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('opencode')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'opencode'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-opencode`}
|
||||
>
|
||||
<OpenCodeIcon className="w-4 h-4" />
|
||||
OpenCode
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -384,6 +480,95 @@ export function ModelSelector({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenCode Models */}
|
||||
{selectedProvider === 'opencode' && !isOpencodeDisabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
<OpenCodeIcon className="w-4 h-4 text-primary" />
|
||||
OpenCode Model
|
||||
</Label>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-violet-500/40 text-violet-600 dark:text-violet-400">
|
||||
CLI
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{(opencodeModelsLoading || dynamicOpencodeLoading) && allOpencodeModels.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
Loading models...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{dynamicOpencodeError && !dynamicOpencodeLoading && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-red-400">Failed to load OpenCode models</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetchOpencodeModels()}
|
||||
className="text-xs text-red-400 underline hover:no-underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!opencodeModelsLoading &&
|
||||
!dynamicOpencodeLoading &&
|
||||
!dynamicOpencodeError &&
|
||||
allOpencodeModels.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground p-3 border border-dashed rounded-md text-center">
|
||||
No OpenCode models enabled. Enable models in Settings → AI Providers.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model list */}
|
||||
{!opencodeModelsLoading && !dynamicOpencodeLoading && allOpencodeModels.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{allOpencodeModels.map((option) => {
|
||||
const isSelected = selectedModel === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onModelSelect(option.id)}
|
||||
title={option.description}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-${option.id}`}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{option.badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isSelected
|
||||
? 'border-primary-foreground/50 text-primary-foreground'
|
||||
: 'border-muted-foreground/50 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{option.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Palette, Moon, Sun, Type, Sparkles, PanelLeft, Columns2 } from 'lucide-react';
|
||||
import { Palette, Moon, Sun, Type, Sparkles, PanelLeft, Columns2, LayoutList } from 'lucide-react';
|
||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||
import {
|
||||
UI_SANS_FONT_OPTIONS,
|
||||
@@ -28,6 +28,8 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS
|
||||
setDisableSplashScreen,
|
||||
sidebarStyle,
|
||||
setSidebarStyle,
|
||||
defaultSortNewestCardOnTop,
|
||||
setDefaultSortNewestCardOnTop,
|
||||
} = useAppStore();
|
||||
|
||||
// Determine if current theme is light or dark
|
||||
@@ -311,6 +313,31 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board Section */}
|
||||
<div className="space-y-4 pt-6 border-t border-border/50">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<LayoutList className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-foreground font-medium">Board</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="default-sort-newest-card-on-top" className="text-sm">
|
||||
Sort Newest First
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sort all cards by creation date (newest on top) across all board columns and list
|
||||
view.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="default-sort-newest-card-on-top"
|
||||
checked={defaultSortNewestCardOnTop}
|
||||
onCheckedChange={setDefaultSortNewestCardOnTop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -177,6 +177,7 @@ export function PhaseModelSelector({
|
||||
enabledCursorModels,
|
||||
enabledGeminiModels,
|
||||
enabledCopilotModels,
|
||||
enabledOpencodeModels,
|
||||
favoriteModels,
|
||||
toggleFavoriteModel,
|
||||
codexModels,
|
||||
@@ -192,6 +193,7 @@ export function PhaseModelSelector({
|
||||
enabledCursorModels: state.enabledCursorModels,
|
||||
enabledGeminiModels: state.enabledGeminiModels,
|
||||
enabledCopilotModels: state.enabledCopilotModels,
|
||||
enabledOpencodeModels: state.enabledOpencodeModels,
|
||||
favoriteModels: state.favoriteModels,
|
||||
toggleFavoriteModel: state.toggleFavoriteModel,
|
||||
codexModels: state.codexModels,
|
||||
@@ -565,8 +567,10 @@ export function PhaseModelSelector({
|
||||
|
||||
// Combine static and dynamic OpenCode models
|
||||
const allOpencodeModels: ModelOption[] = useMemo(() => {
|
||||
// Start with static models
|
||||
const staticModels = [...OPENCODE_MODELS];
|
||||
// Filter static models by what the user has enabled in Settings → AI Providers
|
||||
const staticModels = OPENCODE_MODELS.filter((model) =>
|
||||
(enabledOpencodeModels as string[]).includes(model.id)
|
||||
);
|
||||
|
||||
// Add dynamic models (convert ModelDefinition to ModelOption)
|
||||
// Only include dynamic models that are enabled by the user
|
||||
@@ -580,13 +584,21 @@ export function PhaseModelSelector({
|
||||
provider: 'opencode' as const,
|
||||
}));
|
||||
|
||||
// Merge, avoiding duplicates (static models take precedence for same ID)
|
||||
// In practice, static and dynamic IDs don't overlap
|
||||
const staticIds = new Set(staticModels.map((m) => m.id));
|
||||
const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id));
|
||||
// Merge, avoiding duplicates (static models take precedence)
|
||||
// Static IDs use dash format (opencode-glm-5-free), dynamic use slash format (opencode/glm-5-free)
|
||||
// Normalize both to the model name part for comparison
|
||||
const normalizeModelName = (id: string): string => {
|
||||
if (id.startsWith('opencode-')) return id.slice('opencode-'.length);
|
||||
if (id.startsWith('opencode/')) return id.slice('opencode/'.length);
|
||||
return id;
|
||||
};
|
||||
const staticModelNames = new Set(staticModels.map((m) => normalizeModelName(m.id)));
|
||||
const uniqueDynamic = dynamicModelOptions.filter(
|
||||
(m) => !staticModelNames.has(normalizeModelName(m.id))
|
||||
);
|
||||
|
||||
return [...staticModels, ...uniqueDynamic];
|
||||
}, [dynamicOpencodeModels, enabledDynamicModelIds]);
|
||||
}, [enabledOpencodeModels, dynamicOpencodeModels, enabledDynamicModelIds]);
|
||||
|
||||
// Check if providers are disabled (needed for rendering conditions)
|
||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||
|
||||
@@ -174,6 +174,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
|
||||
muteDoneSound: state.muteDoneSound as boolean,
|
||||
disableSplashScreen: state.disableSplashScreen as boolean,
|
||||
defaultSortNewestCardOnTop: state.defaultSortNewestCardOnTop as boolean,
|
||||
enhancementModel: state.enhancementModel as GlobalSettings['enhancementModel'],
|
||||
validationModel: state.validationModel as GlobalSettings['validationModel'],
|
||||
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
|
||||
@@ -685,6 +686,12 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
(modelId) => !modelId.startsWith('amazon-bedrock/')
|
||||
);
|
||||
|
||||
const persistedKnownDynamicModelIds =
|
||||
settings.knownDynamicModelIds ?? current.knownDynamicModelIds;
|
||||
const sanitizedKnownDynamicModelIds = persistedKnownDynamicModelIds.filter(
|
||||
(modelId) => !modelId.startsWith('amazon-bedrock/')
|
||||
);
|
||||
|
||||
// Convert ProjectRef[] to Project[] (minimal data, features will be loaded separately)
|
||||
const projects = (settings.projects ?? []).map((ref) => ({
|
||||
id: ref.id,
|
||||
@@ -764,6 +771,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
},
|
||||
muteDoneSound: settings.muteDoneSound ?? false,
|
||||
disableSplashScreen: settings.disableSplashScreen ?? false,
|
||||
defaultSortNewestCardOnTop: settings.defaultSortNewestCardOnTop ?? false,
|
||||
serverLogLevel: settings.serverLogLevel ?? 'info',
|
||||
enableRequestLogging: settings.enableRequestLogging ?? true,
|
||||
showQueryDevtools: settings.showQueryDevtools ?? true,
|
||||
@@ -777,6 +785,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||
knownDynamicModelIds: sanitizedKnownDynamicModelIds,
|
||||
disabledProviders: settings.disabledProviders ?? [],
|
||||
enableAiCommitMessages: settings.enableAiCommitMessages ?? true,
|
||||
enableSkills: settings.enableSkills ?? true,
|
||||
@@ -906,6 +915,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||
muteDoneSound: state.muteDoneSound,
|
||||
disableSplashScreen: state.disableSplashScreen,
|
||||
defaultSortNewestCardOnTop: state.defaultSortNewestCardOnTop,
|
||||
serverLogLevel: state.serverLogLevel,
|
||||
enableRequestLogging: state.enableRequestLogging,
|
||||
enhancementModel: state.enhancementModel,
|
||||
@@ -914,6 +924,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
defaultThinkingLevel: state.defaultThinkingLevel,
|
||||
defaultReasoningEffort: state.defaultReasoningEffort,
|
||||
enabledDynamicModelIds: state.enabledDynamicModelIds,
|
||||
knownDynamicModelIds: state.knownDynamicModelIds,
|
||||
disabledProviders: state.disabledProviders,
|
||||
enableAiCommitMessages: state.enableAiCommitMessages,
|
||||
enableSkills: state.enableSkills,
|
||||
|
||||
@@ -69,6 +69,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'defaultFeatureModel',
|
||||
'muteDoneSound',
|
||||
'disableSplashScreen',
|
||||
'defaultSortNewestCardOnTop',
|
||||
'serverLogLevel',
|
||||
'enableRequestLogging',
|
||||
'showQueryDevtools',
|
||||
@@ -86,6 +87,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'enabledCopilotModels',
|
||||
'copilotDefaultModel',
|
||||
'enabledDynamicModelIds',
|
||||
'knownDynamicModelIds',
|
||||
'disabledProviders',
|
||||
'autoLoadClaudeMd',
|
||||
'useClaudeCodeSystemPrompt',
|
||||
@@ -482,6 +484,14 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the sort preference changed, sync immediately so it survives a page refresh
|
||||
// before the debounce timer fires (1s debounce would be lost on quick refresh).
|
||||
if (newState.defaultSortNewestCardOnTop !== prevState.defaultSortNewestCardOnTop) {
|
||||
logger.debug('defaultSortNewestCardOnTop changed, syncing immediately');
|
||||
syncNow();
|
||||
return;
|
||||
}
|
||||
|
||||
// If projects array changed *meaningfully*, sync immediately.
|
||||
// This is critical — projects list changes must sync right away to prevent loss
|
||||
// when switching between Electron and web modes or closing the app.
|
||||
@@ -705,6 +715,12 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
(modelId) => !modelId.startsWith('amazon-bedrock/')
|
||||
);
|
||||
|
||||
const persistedKnownDynamicModelIds =
|
||||
serverSettings.knownDynamicModelIds ?? currentAppState.knownDynamicModelIds;
|
||||
const sanitizedKnownDynamicModelIds = persistedKnownDynamicModelIds.filter(
|
||||
(modelId) => !modelId.startsWith('amazon-bedrock/')
|
||||
);
|
||||
|
||||
// Migrate phase models to canonical format
|
||||
const migratedPhaseModels = serverSettings.phaseModels
|
||||
? {
|
||||
@@ -788,6 +804,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
muteDoneSound: serverSettings.muteDoneSound,
|
||||
defaultMaxTurns: serverSettings.defaultMaxTurns ?? 10000,
|
||||
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
|
||||
defaultSortNewestCardOnTop: serverSettings.defaultSortNewestCardOnTop ?? false,
|
||||
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
||||
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
||||
enhancementModel: serverSettings.enhancementModel,
|
||||
@@ -807,6 +824,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
enabledCopilotModels: sanitizedEnabledCopilotModels,
|
||||
copilotDefaultModel: sanitizedCopilotDefaultModel,
|
||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||
knownDynamicModelIds: sanitizedKnownDynamicModelIds,
|
||||
disabledProviders: serverSettings.disabledProviders ?? [],
|
||||
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? true,
|
||||
useClaudeCodeSystemPrompt: serverSettings.useClaudeCodeSystemPrompt ?? true,
|
||||
|
||||
@@ -93,6 +93,35 @@ export function formatModelName(model: string, options?: FormatModelNameOptions)
|
||||
return model.replace('cursor-', 'Cursor ').replace('gemini', 'Gemini');
|
||||
if (model.startsWith('cursor-grok')) return 'Cursor Grok';
|
||||
|
||||
// OpenCode static models (canonical opencode- prefix)
|
||||
if (model === 'opencode-big-pickle') return 'Big Pickle';
|
||||
if (model === 'opencode-glm-5-free') return 'GLM 5 Free';
|
||||
if (model === 'opencode-gpt-5-nano') return 'GPT-5 Nano';
|
||||
if (model === 'opencode-kimi-k2.5-free') return 'Kimi K2.5';
|
||||
if (model === 'opencode-minimax-m2.5-free') return 'MiniMax M2.5';
|
||||
|
||||
// OpenCode dynamic models (provider/model format like "google/gemini-2.5-pro")
|
||||
if (model.includes('/') && !model.includes('://')) {
|
||||
const slashIndex = model.indexOf('/');
|
||||
const modelName = model.substring(slashIndex + 1);
|
||||
// Extract last path segment (handles nested paths like "arcee-ai/trinity-large-preview:free")
|
||||
let lastSegment = modelName.split('/').pop()!;
|
||||
// Detect and save tier suffixes like ":free", ":extended", ":beta", ":preview"
|
||||
const tierMatch = lastSegment.match(/:(free|extended|beta|preview)$/i);
|
||||
if (tierMatch) {
|
||||
lastSegment = lastSegment.slice(0, lastSegment.length - tierMatch[0].length);
|
||||
}
|
||||
// Clean up the model name for display (remove version tags, capitalize)
|
||||
const cleanedName = lastSegment.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
// Append tier as a human-friendly label in parentheses
|
||||
if (tierMatch) {
|
||||
const capitalizedTier =
|
||||
tierMatch[1].charAt(0).toUpperCase() + tierMatch[1].slice(1).toLowerCase();
|
||||
return `${cleanedName} (${capitalizedTier})`;
|
||||
}
|
||||
return cleanedName;
|
||||
}
|
||||
|
||||
// Default: split by dash and capitalize
|
||||
return model.split('-').slice(1, 3).join(' ');
|
||||
}
|
||||
|
||||
@@ -311,6 +311,7 @@ const initialState: AppState = {
|
||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
muteDoneSound: false,
|
||||
disableSplashScreen: false,
|
||||
defaultSortNewestCardOnTop: false,
|
||||
serverLogLevel: 'info',
|
||||
enableRequestLogging: true,
|
||||
showQueryDevtools: true,
|
||||
@@ -333,6 +334,7 @@ const initialState: AppState = {
|
||||
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
|
||||
dynamicOpencodeModels: [],
|
||||
enabledDynamicModelIds: [],
|
||||
knownDynamicModelIds: [],
|
||||
cachedOpencodeProviders: [],
|
||||
opencodeModelsLoading: false,
|
||||
opencodeModelsError: null,
|
||||
@@ -1233,6 +1235,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Splash Screen actions
|
||||
setDisableSplashScreen: (disabled) => set({ disableSplashScreen: disabled }),
|
||||
|
||||
// Board Card Sorting (global default) actions
|
||||
setDefaultSortNewestCardOnTop: (enabled) => set({ defaultSortNewestCardOnTop: enabled }),
|
||||
|
||||
// Server Log Level actions
|
||||
setServerLogLevel: (level) => set({ serverLogLevel: level }),
|
||||
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
|
||||
@@ -1355,21 +1360,52 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
|
||||
// OpenCode CLI Settings actions
|
||||
setEnabledOpencodeModels: (models) => set({ enabledOpencodeModels: models }),
|
||||
setOpencodeDefaultModel: (model) => set({ opencodeDefaultModel: model }),
|
||||
toggleOpencodeModel: (model, enabled) =>
|
||||
setOpencodeDefaultModel: async (model) => {
|
||||
set({ opencodeDefaultModel: model });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ opencodeDefaultModel: model });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync opencodeDefaultModel:', error);
|
||||
}
|
||||
},
|
||||
toggleOpencodeModel: async (model, enabled) => {
|
||||
set((state) => ({
|
||||
enabledOpencodeModels: enabled
|
||||
? [...state.enabledOpencodeModels, model]
|
||||
? [...new Set([...state.enabledOpencodeModels, model])]
|
||||
: state.enabledOpencodeModels.filter((m) => m !== model),
|
||||
})),
|
||||
}));
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ enabledOpencodeModels: get().enabledOpencodeModels });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync enabledOpencodeModels:', error);
|
||||
}
|
||||
},
|
||||
setDynamicOpencodeModels: (models) => set({ dynamicOpencodeModels: models }),
|
||||
setEnabledDynamicModelIds: (ids) => set({ enabledDynamicModelIds: ids }),
|
||||
toggleDynamicModel: (modelId, enabled) =>
|
||||
setEnabledDynamicModelIds: async (ids) => {
|
||||
const deduped = Array.from(new Set(ids));
|
||||
set({ enabledDynamicModelIds: deduped });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ enabledDynamicModelIds: deduped });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync enabledDynamicModelIds:', error);
|
||||
}
|
||||
},
|
||||
toggleDynamicModel: async (modelId, enabled) => {
|
||||
set((state) => ({
|
||||
enabledDynamicModelIds: enabled
|
||||
? [...state.enabledDynamicModelIds, modelId]
|
||||
? [...new Set([...state.enabledDynamicModelIds, modelId])]
|
||||
: state.enabledDynamicModelIds.filter((id) => id !== modelId),
|
||||
})),
|
||||
}));
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ enabledDynamicModelIds: get().enabledDynamicModelIds });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync enabledDynamicModelIds:', error);
|
||||
}
|
||||
},
|
||||
setCachedOpencodeProviders: (providers) => set({ cachedOpencodeProviders: providers }),
|
||||
|
||||
// Gemini CLI Settings actions
|
||||
@@ -2877,13 +2913,43 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
(m) => !m.id.startsWith(OPENCODE_BEDROCK_MODEL_PREFIX)
|
||||
);
|
||||
|
||||
// Auto-enable only models that are genuinely new (never seen before).
|
||||
// Models that existed previously and were explicitly deselected by the user
|
||||
// should NOT be re-enabled on subsequent fetches.
|
||||
const currentEnabledIds = get().enabledDynamicModelIds;
|
||||
const currentKnownIds = get().knownDynamicModelIds;
|
||||
const allFetchedIds = filteredModels.map((m) => m.id);
|
||||
// Only auto-enable models that have NEVER been seen before (not in knownDynamicModelIds)
|
||||
const trulyNewModelIds = allFetchedIds.filter((id) => !currentKnownIds.includes(id));
|
||||
const updatedEnabledIds =
|
||||
trulyNewModelIds.length > 0
|
||||
? [...new Set([...currentEnabledIds, ...trulyNewModelIds])]
|
||||
: currentEnabledIds;
|
||||
// Track all discovered model IDs (union of known + newly fetched)
|
||||
const updatedKnownIds = [...new Set([...currentKnownIds, ...allFetchedIds])];
|
||||
|
||||
set({
|
||||
dynamicOpencodeModels: filteredModels,
|
||||
enabledDynamicModelIds: updatedEnabledIds,
|
||||
knownDynamicModelIds: updatedKnownIds,
|
||||
cachedOpencodeProviders: data.providers ?? [],
|
||||
opencodeModelsLoading: false,
|
||||
opencodeModelsLastFetched: now,
|
||||
opencodeModelsError: null,
|
||||
});
|
||||
|
||||
// Persist newly enabled model IDs and known model IDs to server settings
|
||||
if (trulyNewModelIds.length > 0) {
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({
|
||||
enabledDynamicModelIds: updatedEnabledIds,
|
||||
knownDynamicModelIds: updatedKnownIds,
|
||||
});
|
||||
} catch (syncError) {
|
||||
logger.error('Failed to sync enabledDynamicModelIds after auto-enable:', syncError);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
set({
|
||||
opencodeModelsLoading: false,
|
||||
|
||||
@@ -168,6 +168,9 @@ export interface AppState {
|
||||
// Splash Screen Settings
|
||||
disableSplashScreen: boolean; // When true, skip showing the splash screen overlay on startup
|
||||
|
||||
// Board Card Sorting (global default)
|
||||
defaultSortNewestCardOnTop: boolean; // Global default: sort latest card on top in board columns and list view
|
||||
|
||||
// Server Log Level Settings
|
||||
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
|
||||
enableRequestLogging: boolean; // Enable HTTP request logging (Morgan)
|
||||
@@ -215,6 +218,7 @@ export interface AppState {
|
||||
// from `opencode models` CLI and depend on current provider authentication state
|
||||
dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI
|
||||
enabledDynamicModelIds: string[]; // Which dynamic models are enabled
|
||||
knownDynamicModelIds: string[]; // All dynamic model IDs ever seen (used to avoid re-enabling explicitly deselected models)
|
||||
cachedOpencodeProviders: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -574,6 +578,9 @@ export interface AppActions {
|
||||
// Splash Screen actions
|
||||
setDisableSplashScreen: (disabled: boolean) => void;
|
||||
|
||||
// Board Card Sorting (global default) actions
|
||||
setDefaultSortNewestCardOnTop: (enabled: boolean) => void;
|
||||
|
||||
// Server Log Level actions
|
||||
setServerLogLevel: (level: ServerLogLevel) => void;
|
||||
setEnableRequestLogging: (enabled: boolean) => void;
|
||||
@@ -616,12 +623,16 @@ export interface AppActions {
|
||||
setCodexEnableImages: (enabled: boolean) => Promise<void>;
|
||||
|
||||
// OpenCode CLI Settings actions
|
||||
// Note: setOpencodeDefaultModel, toggleOpencodeModel, setEnabledDynamicModelIds, and
|
||||
// toggleDynamicModel return Promise<void> because they persist state to the server.
|
||||
// TODO: harmonize other provider action types (e.g., setCursorDefaultModel, toggleCursorModel,
|
||||
// setGeminiDefaultModel) to also return Promise<void> for consistent async persistence.
|
||||
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
|
||||
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
|
||||
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => void;
|
||||
setOpencodeDefaultModel: (model: OpencodeModelId) => Promise<void>;
|
||||
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => Promise<void>;
|
||||
setDynamicOpencodeModels: (models: ModelDefinition[]) => void;
|
||||
setEnabledDynamicModelIds: (ids: string[]) => void;
|
||||
toggleDynamicModel: (modelId: string, enabled: boolean) => void;
|
||||
setEnabledDynamicModelIds: (ids: string[]) => Promise<void>;
|
||||
toggleDynamicModel: (modelId: string, enabled: boolean) => Promise<void>;
|
||||
setCachedOpencodeProviders: (
|
||||
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
|
||||
) => void;
|
||||
|
||||
@@ -92,6 +92,8 @@ export type EventType =
|
||||
| 'switch:pop'
|
||||
| 'switch:done'
|
||||
| 'switch:error'
|
||||
| 'notification:created';
|
||||
| 'notification:created'
|
||||
| 'worktree:deleted'
|
||||
| 'feature:migrated';
|
||||
|
||||
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||
|
||||
@@ -104,6 +104,7 @@ export interface Feature {
|
||||
planSpec?: PlanSpec;
|
||||
error?: string;
|
||||
summary?: string;
|
||||
createdAt?: string; // ISO timestamp when feature was created
|
||||
startedAt?: string;
|
||||
descriptionHistory?: DescriptionHistoryEntry[]; // History of description changes
|
||||
[key: string]: unknown; // Keep catch-all for extensibility
|
||||
|
||||
@@ -1198,6 +1198,10 @@ export interface GlobalSettings {
|
||||
/** Disable the splash screen overlay on app startup */
|
||||
disableSplashScreen: boolean;
|
||||
|
||||
// Board Card Sorting
|
||||
/** Default: sort latest card on top in board columns and list view. Per-project setting overrides this. Default: false */
|
||||
defaultSortNewestCardOnTop?: boolean;
|
||||
|
||||
// Server Logging Preferences
|
||||
/** Log level for the API server (error, warn, info, debug). Default: info */
|
||||
serverLogLevel?: ServerLogLevel;
|
||||
@@ -1253,6 +1257,8 @@ export interface GlobalSettings {
|
||||
opencodeDefaultModel?: OpencodeModelId;
|
||||
/** Which dynamic OpenCode models are enabled (empty = all discovered) */
|
||||
enabledDynamicModelIds?: string[];
|
||||
/** All dynamic model IDs ever seen - used to distinguish new models from explicitly deselected ones */
|
||||
knownDynamicModelIds?: string[];
|
||||
|
||||
// Gemini CLI Settings (global)
|
||||
/** Which Gemini models are available in feature modal (empty = all) */
|
||||
@@ -1765,6 +1771,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
defaultFeatureModel: { model: 'claude-opus', thinkingLevel: 'adaptive' }, // Use canonical ID with adaptive thinking
|
||||
muteDoneSound: false,
|
||||
disableSplashScreen: false,
|
||||
defaultSortNewestCardOnTop: false,
|
||||
serverLogLevel: 'info',
|
||||
enableRequestLogging: true,
|
||||
showQueryDevtools: true,
|
||||
@@ -1780,6 +1787,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
enabledOpencodeModels: getAllOpencodeModelIds(), // Returns prefixed IDs
|
||||
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Already prefixed
|
||||
enabledDynamicModelIds: [],
|
||||
knownDynamicModelIds: [],
|
||||
enabledGeminiModels: getAllGeminiModelIds(), // Returns prefixed IDs
|
||||
geminiDefaultModel: DEFAULT_GEMINI_MODEL, // Already prefixed
|
||||
enabledCopilotModels: getAllCopilotModelIds(), // Returns prefixed IDs
|
||||
|
||||
Reference in New Issue
Block a user