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:
gsxdsm
2026-02-28 15:42:10 -08:00
committed by GitHub
parent 1c0e460dd1
commit 63b0a4fb38
29 changed files with 838 additions and 85 deletions

View File

@@ -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));

View File

@@ -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})`;
}
/**

View File

@@ -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(

View File

@@ -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) {

View File

@@ -378,6 +378,7 @@ export class FeatureLoader {
description: featureData.description || '',
...featureData,
id: featureId,
createdAt: featureData.createdAt || new Date().toISOString(),
imagePaths: migratedImagePaths,
descriptionHistory: initialHistory,
};

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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"
/>
) : (

View File

@@ -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);

View File

@@ -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>

View File

@@ -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:

View File

@@ -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(

View File

@@ -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 = () => {
setShowVerifyDialog(false);
onVerify?.();
const handleConfirmVerify = async () => {
if (!onVerify) {
setShowVerifyDialog(false);
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"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Verify
{isVerifying ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 mr-2" />
)}
{isVerifying ? 'Verifying...' : 'Verify'}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -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>
</>
)}

View File

@@ -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);

View File

@@ -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

View File

@@ -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,9 +416,34 @@ export function useBoardColumnFeatures({
}
}
map.backlog = [...unblocked, ...blocked];
if (sortNewestCardOnTop) {
// Sort each group newest-first while keeping dependency chains nested
map.backlog = [
...sortNewestWithDependencies(unblocked),
...sortNewestWithDependencies(blocked),
];
} else {
map.backlog = [...unblocked, ...blocked];
}
} else {
map.backlog = orderedFeatures;
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
});
}
}
@@ -289,6 +457,7 @@ export function useBoardColumnFeatures({
currentWorktreeBranch,
projectPath,
recentlyCompletedFeatures,
sortNewestCardOnTop,
]);
const getColumnFeatures = useCallback(

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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');

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(' ');
}

View File

@@ -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,

View File

@@ -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;