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/auto-mode', createAutoModeRoutes(autoModeService));
|
||||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
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/git', createGitRoutes());
|
||||||
app.use('/api/models', createModelsRoutes());
|
app.use('/api/models', createModelsRoutes());
|
||||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||||
|
|||||||
@@ -1189,8 +1189,26 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
* Format a display name for a model
|
* Format a display name for a model
|
||||||
*/
|
*/
|
||||||
private formatModelDisplayName(model: OpenCodeModelInfo): string {
|
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
|
// Capitalize and format the model name
|
||||||
const formattedName = model.name
|
const formattedName = rawName
|
||||||
.split('-')
|
.split('-')
|
||||||
.map((part) => {
|
.map((part) => {
|
||||||
// Handle version numbers like "4-5" -> "4.5"
|
// Handle version numbers like "4-5" -> "4.5"
|
||||||
@@ -1218,7 +1236,7 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const providerDisplay = providerNames[model.provider] || model.provider;
|
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 { createSyncHandler } from './routes/sync.js';
|
||||||
import { createUpdatePRNumberHandler } from './routes/update-pr-number.js';
|
import { createUpdatePRNumberHandler } from './routes/update-pr-number.js';
|
||||||
import type { SettingsService } from '../../services/settings-service.js';
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
|
import type { FeatureLoader } from '../../services/feature-loader.js';
|
||||||
|
|
||||||
export function createWorktreeRoutes(
|
export function createWorktreeRoutes(
|
||||||
events: EventEmitter,
|
events: EventEmitter,
|
||||||
settingsService?: SettingsService
|
settingsService?: SettingsService,
|
||||||
|
featureLoader?: FeatureLoader
|
||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -94,7 +96,11 @@ export function createWorktreeRoutes(
|
|||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
createCreateHandler(events, settingsService)
|
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('/create-pr', createCreatePRHandler());
|
||||||
router.post('/pr-info', createPRInfoHandler());
|
router.post('/pr-info', createPRInfoHandler());
|
||||||
router.post(
|
router.post(
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import { isGitRepo } from '@automaker/git-utils';
|
|||||||
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||||
import { execGitCommand } from '../../../lib/git.js';
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
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 execAsync = promisify(exec);
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
export function createDeleteHandler() {
|
export function createDeleteHandler(events: EventEmitter, featureLoader?: FeatureLoader) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, worktreePath, deleteBranch } = req.body as {
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
deleted: {
|
deleted: {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
branch: branchDeleted ? branchName : null,
|
branch: branchDeleted ? branchName : null,
|
||||||
branchDeleted,
|
branchDeleted,
|
||||||
|
featuresMovedToMain,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ export class FeatureLoader {
|
|||||||
description: featureData.description || '',
|
description: featureData.description || '',
|
||||||
...featureData,
|
...featureData,
|
||||||
id: featureId,
|
id: featureId,
|
||||||
|
createdAt: featureData.createdAt || new Date().toISOString(),
|
||||||
imagePaths: migratedImagePaths,
|
imagePaths: migratedImagePaths,
|
||||||
descriptionHistory: initialHistory,
|
descriptionHistory: initialHistory,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -312,7 +312,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
|
<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">
|
<SheetTitle className="flex items-center gap-2">
|
||||||
<ImageIcon className="w-5 h-5 text-brand-500" />
|
<ImageIcon className="w-5 h-5 text-brand-500" />
|
||||||
Board Background Settings
|
Board Background Settings
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ const SheetContent = ({ className, children, side = 'right', ...props }: SheetCo
|
|||||||
const Close = SheetPrimitive.Close as React.ComponentType<{
|
const Close = SheetPrimitive.Close as React.ComponentType<{
|
||||||
className: string;
|
className: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
'data-slot'?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -79,7 +81,13 @@ const SheetContent = ({ className, children, side = 'right', ...props }: SheetCo
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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" />
|
<XIcon className="size-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</Close>
|
</Close>
|
||||||
|
|||||||
@@ -27,22 +27,22 @@ export function AgentHeader({
|
|||||||
worktreeBranch,
|
worktreeBranch,
|
||||||
}: AgentHeaderProps) {
|
}: AgentHeaderProps) {
|
||||||
return (
|
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 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-4">
|
<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">
|
<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" />
|
<Bot className="w-5 h-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||||
<span>
|
<span className="truncate">
|
||||||
{projectName}
|
{projectName}
|
||||||
{currentSessionId && !isConnected && ' - Connecting...'}
|
{currentSessionId && !isConnected && ' - Connecting...'}
|
||||||
</span>
|
</span>
|
||||||
{worktreeBranch && (
|
{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" />
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -50,9 +50,9 @@ export function AgentHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status indicators & actions */}
|
{/* Status indicators & actions */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-1 sm:gap-3 shrink-0">
|
||||||
{currentTool && (
|
{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" />
|
<Wrench className="w-3 h-3 text-primary" />
|
||||||
<span className="font-medium">{currentTool}</span>
|
<span className="font-medium">{currentTool}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,10 +63,11 @@ export function AgentHeader({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={onClearChat}
|
onClick={onClearChat}
|
||||||
disabled={isProcessing}
|
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" />
|
<Trash2 className="w-4 h-4 sm:mr-2" />
|
||||||
Clear
|
<span className="hidden sm:inline">Clear</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export function BoardView() {
|
|||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
setPipelineConfig,
|
setPipelineConfig,
|
||||||
featureTemplates,
|
featureTemplates,
|
||||||
|
defaultSortNewestCardOnTop,
|
||||||
} = useAppStore(
|
} = useAppStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
currentProject: state.currentProject,
|
currentProject: state.currentProject,
|
||||||
@@ -152,6 +153,7 @@ export function BoardView() {
|
|||||||
getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch,
|
||||||
setPipelineConfig: state.setPipelineConfig,
|
setPipelineConfig: state.setPipelineConfig,
|
||||||
featureTemplates: state.featureTemplates,
|
featureTemplates: state.featureTemplates,
|
||||||
|
defaultSortNewestCardOnTop: state.defaultSortNewestCardOnTop,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
// Also get keyboard shortcuts for the add feature shortcut
|
// 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
|
// Use column features hook
|
||||||
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
|
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
|
||||||
features: hookFeatures,
|
features: hookFeatures,
|
||||||
@@ -1467,6 +1474,7 @@ export function BoardView() {
|
|||||||
currentWorktreePath,
|
currentWorktreePath,
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
projectPath: currentProject?.path || null,
|
projectPath: currentProject?.path || null,
|
||||||
|
sortNewestCardOnTop: defaultSortNewestCardOnTop,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build columnFeaturesMap for ListView
|
// Build columnFeaturesMap for ListView
|
||||||
@@ -1480,11 +1488,6 @@ export function BoardView() {
|
|||||||
return map;
|
return map;
|
||||||
}, [pipelineConfig, getColumnFeatures]);
|
}, [pipelineConfig, getColumnFeatures]);
|
||||||
|
|
||||||
// Use background hook
|
|
||||||
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
|
|
||||||
currentProject,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find feature for pending plan approval
|
// Find feature for pending plan approval
|
||||||
const pendingApprovalFeature = useMemo(() => {
|
const pendingApprovalFeature = useMemo(() => {
|
||||||
if (!pendingPlanApproval) return null;
|
if (!pendingPlanApproval) return null;
|
||||||
@@ -1802,6 +1805,7 @@ export function BoardView() {
|
|||||||
handleViewOutput(feature);
|
handleViewOutput(feature);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
sortNewestCardOnTop={defaultSortNewestCardOnTop}
|
||||||
className="transition-opacity duration-200"
|
className="transition-opacity duration-200"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
)}
|
)}
|
||||||
{effectiveTodos.length > 3 && (
|
{effectiveTodos.length > 3 && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsTodosExpanded(!isTodosExpanded);
|
setIsTodosExpanded(!isTodosExpanded);
|
||||||
@@ -481,11 +482,22 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
{effectiveSummary && (
|
{effectiveSummary && (
|
||||||
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
|
<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 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" />
|
<Sparkles className="w-3 h-3 shrink-0" />
|
||||||
<span className="truncate font-medium">Summary</span>
|
<span className="truncate font-medium">Summary</span>
|
||||||
</div>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsSummaryDialogOpen(true);
|
setIsSummaryDialogOpen(true);
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import { memo, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
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 { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
|
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
|
||||||
@@ -147,12 +155,15 @@ export const PriorityBadges = memo(function PriorityBadges({
|
|||||||
excludedStepCount > 0 && totalPipelineSteps > 0 && feature.status === 'backlog';
|
excludedStepCount > 0 && totalPipelineSteps > 0 && feature.status === 'backlog';
|
||||||
const allPipelinesExcluded = hasPipelineExclusions && excludedStepCount >= totalPipelineSteps;
|
const allPipelinesExcluded = hasPipelineExclusions && excludedStepCount >= totalPipelineSteps;
|
||||||
|
|
||||||
|
const showPlanApproval = feature.planSpec?.status === 'generated';
|
||||||
|
|
||||||
const showBadges =
|
const showBadges =
|
||||||
feature.priority ||
|
feature.priority ||
|
||||||
showManualVerification ||
|
showManualVerification ||
|
||||||
isBlocked ||
|
isBlocked ||
|
||||||
isJustFinished ||
|
isJustFinished ||
|
||||||
hasPipelineExclusions;
|
hasPipelineExclusions ||
|
||||||
|
showPlanApproval;
|
||||||
|
|
||||||
if (!showBadges) {
|
if (!showBadges) {
|
||||||
return null;
|
return null;
|
||||||
@@ -264,6 +275,26 @@ export const PriorityBadges = memo(function PriorityBadges({
|
|||||||
</Tooltip>
|
</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 */}
|
{/* Pipeline exclusion badge */}
|
||||||
{hasPipelineExclusions && (
|
{hasPipelineExclusions && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { memo, useCallback, useState, useEffect } from 'react';
|
import { memo, useCallback, useState, useEffect } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
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 type { Feature } from '@/store/app-store';
|
||||||
import { RowActions, type RowActionHandlers } from './row-actions';
|
import { RowActions, type RowActionHandlers } from './row-actions';
|
||||||
import { getColumnWidth, getColumnAlign } from './list-header';
|
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({
|
badges.push({
|
||||||
key: 'plan',
|
key: 'plan',
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
@@ -400,8 +410,13 @@ export function getFeatureSortValue(
|
|||||||
return (feature.category || '').toLowerCase();
|
return (feature.category || '').toLowerCase();
|
||||||
case 'priority':
|
case 'priority':
|
||||||
return feature.priority || 999; // No priority sorts last
|
return feature.priority || 999; // No priority sorts last
|
||||||
case 'createdAt':
|
case 'createdAt': {
|
||||||
return feature.createdAt ? new Date(feature.createdAt) : new Date(0);
|
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':
|
case 'updatedAt':
|
||||||
return feature.updatedAt ? new Date(feature.updatedAt) : new Date(0);
|
return feature.updatedAt ? new Date(feature.updatedAt) : new Date(0);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export interface ListViewProps {
|
|||||||
sortConfig: SortConfig;
|
sortConfig: SortConfig;
|
||||||
/** Callback when sort column is changed */
|
/** Callback when sort column is changed */
|
||||||
onSortChange: (column: SortColumn) => void;
|
onSortChange: (column: SortColumn) => void;
|
||||||
|
/** When true, always sort by most recent (createdAt desc), overriding the current sort config */
|
||||||
|
sortNewestCardOnTop?: boolean;
|
||||||
/** Action handlers for rows */
|
/** Action handlers for rows */
|
||||||
actionHandlers: ListViewActionHandlers;
|
actionHandlers: ListViewActionHandlers;
|
||||||
/** Set of feature IDs that are currently running */
|
/** Set of feature IDs that are currently running */
|
||||||
@@ -229,6 +231,7 @@ export const ListView = memo(function ListView({
|
|||||||
onToggleFeatureSelection,
|
onToggleFeatureSelection,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
className,
|
className,
|
||||||
|
sortNewestCardOnTop = false,
|
||||||
}: ListViewProps) {
|
}: ListViewProps) {
|
||||||
// Track collapsed state for each status group
|
// Track collapsed state for each status group
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||||
@@ -239,14 +242,23 @@ export const ListView = memo(function ListView({
|
|||||||
|
|
||||||
// Generate status groups from columnFeaturesMap
|
// Generate status groups from columnFeaturesMap
|
||||||
const statusGroups = useMemo<StatusGroup[]>(() => {
|
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 columns = getColumnsWithPipeline(pipelineConfig);
|
||||||
const groups: StatusGroup[] = [];
|
const groups: StatusGroup[] = [];
|
||||||
|
|
||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
const features = columnFeaturesMap[column.id] || [];
|
const features = columnFeaturesMap[column.id] || [];
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
// Sort features within the group according to current sort config
|
// Sort features within the group according to effective sort config
|
||||||
const sortedFeatures = sortFeatures(features, sortConfig.column, sortConfig.direction);
|
const sortedFeatures = sortFeatures(
|
||||||
|
features,
|
||||||
|
effectiveSortConfig.column,
|
||||||
|
effectiveSortConfig.direction
|
||||||
|
);
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
id: column.id as FeatureStatusWithPipeline,
|
id: column.id as FeatureStatusWithPipeline,
|
||||||
@@ -259,7 +271,7 @@ export const ListView = memo(function ListView({
|
|||||||
|
|
||||||
// Sort groups by status order
|
// Sort groups by status order
|
||||||
return groups.sort((a, b) => getStatusOrder(a.id) - getStatusOrder(b.id));
|
return groups.sort((a, b) => getStatusOrder(a.id) - getStatusOrder(b.id));
|
||||||
}, [columnFeaturesMap, pipelineConfig, sortConfig]);
|
}, [columnFeaturesMap, pipelineConfig, sortNewestCardOnTop, sortConfig]);
|
||||||
|
|
||||||
// Calculate total feature count
|
// Calculate total feature count
|
||||||
const totalFeatures = useMemo(
|
const totalFeatures = useMemo(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -18,7 +18,7 @@ interface SelectionActionBarProps {
|
|||||||
totalCount: number;
|
totalCount: number;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onVerify?: () => void;
|
onVerify?: () => Promise<void> | void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
mode?: SelectionActionMode;
|
mode?: SelectionActionMode;
|
||||||
@@ -36,6 +36,7 @@ export function SelectionActionBar({
|
|||||||
}: SelectionActionBarProps) {
|
}: SelectionActionBarProps) {
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [showVerifyDialog, setShowVerifyDialog] = useState(false);
|
const [showVerifyDialog, setShowVerifyDialog] = useState(false);
|
||||||
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
|
|
||||||
const allSelected = selectedCount === totalCount && totalCount > 0;
|
const allSelected = selectedCount === totalCount && totalCount > 0;
|
||||||
|
|
||||||
@@ -49,12 +50,22 @@ export function SelectionActionBar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleVerifyClick = () => {
|
const handleVerifyClick = () => {
|
||||||
|
if (!onVerify) return;
|
||||||
setShowVerifyDialog(true);
|
setShowVerifyDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmVerify = () => {
|
const handleConfirmVerify = async () => {
|
||||||
|
if (!onVerify) {
|
||||||
setShowVerifyDialog(false);
|
setShowVerifyDialog(false);
|
||||||
onVerify?.();
|
return;
|
||||||
|
}
|
||||||
|
setIsVerifying(true);
|
||||||
|
try {
|
||||||
|
await onVerify();
|
||||||
|
} finally {
|
||||||
|
setIsVerifying(false);
|
||||||
|
setShowVerifyDialog(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -112,7 +123,7 @@ export function SelectionActionBar({
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleVerifyClick}
|
onClick={handleVerifyClick}
|
||||||
disabled={selectedCount === 0}
|
disabled={selectedCount === 0 || !onVerify}
|
||||||
className="h-8 bg-green-600 hover:bg-green-700 disabled:opacity-50"
|
className="h-8 bg-green-600 hover:bg-green-700 disabled:opacity-50"
|
||||||
data-testid="selection-verify-button"
|
data-testid="selection-verify-button"
|
||||||
>
|
>
|
||||||
@@ -184,7 +195,12 @@ export function SelectionActionBar({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Verify Confirmation 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">
|
<DialogContent data-testid="bulk-verify-confirmation-dialog">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-green-600">
|
<DialogTitle className="flex items-center gap-2 text-green-600">
|
||||||
@@ -203,6 +219,7 @@ export function SelectionActionBar({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setShowVerifyDialog(false)}
|
onClick={() => setShowVerifyDialog(false)}
|
||||||
|
disabled={isVerifying}
|
||||||
data-testid="cancel-bulk-verify-button"
|
data-testid="cancel-bulk-verify-button"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -210,10 +227,15 @@ export function SelectionActionBar({
|
|||||||
<Button
|
<Button
|
||||||
className="bg-green-600 hover:bg-green-700"
|
className="bg-green-600 hover:bg-green-700"
|
||||||
onClick={handleConfirmVerify}
|
onClick={handleConfirmVerify}
|
||||||
|
disabled={isVerifying || !onVerify}
|
||||||
data-testid="confirm-bulk-verify-button"
|
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" />
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||||
Verify
|
)}
|
||||||
|
{isVerifying ? 'Verifying...' : 'Verify'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -85,7 +85,10 @@ export function PlanApprovalDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{viewOnly ? 'View Plan' : 'Review Plan'}
|
{viewOnly ? 'View Plan' : 'Review Plan'}
|
||||||
@@ -146,12 +149,12 @@ export function PlanApprovalDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Plan Content */}
|
{/* 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 ? (
|
{isEditMode && !viewOnly ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
value={editedPlan}
|
value={editedPlan}
|
||||||
onChange={(e) => setEditedPlan(e.target.value)}
|
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..."
|
placeholder="Enter plan content..."
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -179,17 +182,31 @@ export function PlanApprovalDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="flex-shrink-0 gap-2">
|
<DialogFooter className="flex-shrink-0 gap-2 flex-col sm:flex-row">
|
||||||
{viewOnly ? (
|
{viewOnly ? (
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
) : showRejectFeedback ? (
|
) : showRejectFeedback ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={handleCancelReject} disabled={isLoading}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleCancelReject}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={handleReject} disabled={isLoading}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Spinner size="sm" className="mr-2" />
|
<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" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
Request Changes
|
Request Changes
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleApprove}
|
onClick={handleApprove}
|
||||||
disabled={isLoading}
|
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 ? (
|
{isLoading ? (
|
||||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
) : (
|
) : (
|
||||||
<Check className="w-4 h-4 mr-2" />
|
<Check className="w-4 h-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
Approve
|
Approve Plan
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ export function useBoardActions({
|
|||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
dependencies: featureData.dependencies || [],
|
dependencies: featureData.dependencies || [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
...(initialStatus === 'in_progress' ? { startedAt: new Date().toISOString() } : {}),
|
...(initialStatus === 'in_progress' ? { startedAt: new Date().toISOString() } : {}),
|
||||||
};
|
};
|
||||||
const createdFeature = addFeature(newFeatureData);
|
const createdFeature = addFeature(newFeatureData);
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
|
|||||||
|
|
||||||
// Get background settings for current project
|
// Get background settings for current project
|
||||||
const backgroundSettings = useMemo(() => {
|
const backgroundSettings = useMemo(() => {
|
||||||
return (
|
const perProjectSettings = currentProject
|
||||||
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings
|
? boardBackgroundByProject[currentProject.path]
|
||||||
);
|
: null;
|
||||||
|
return perProjectSettings || defaultBackgroundSettings;
|
||||||
}, [currentProject, boardBackgroundByProject]);
|
}, [currentProject, boardBackgroundByProject]);
|
||||||
|
|
||||||
// Build background image style if image exists
|
// Build background image style if image exists
|
||||||
|
|||||||
@@ -9,6 +9,147 @@ import {
|
|||||||
|
|
||||||
type ColumnId = Feature['status'];
|
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 {
|
interface UseBoardColumnFeaturesProps {
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
@@ -17,6 +158,7 @@ interface UseBoardColumnFeaturesProps {
|
|||||||
currentWorktreePath: string | null; // Currently selected worktree path
|
currentWorktreePath: string | null; // Currently selected worktree path
|
||||||
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
|
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
|
||||||
projectPath: string | null; // Main project path (for main worktree)
|
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({
|
export function useBoardColumnFeatures({
|
||||||
@@ -27,6 +169,7 @@ export function useBoardColumnFeatures({
|
|||||||
currentWorktreePath,
|
currentWorktreePath,
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
sortNewestCardOnTop = false,
|
||||||
}: UseBoardColumnFeaturesProps) {
|
}: UseBoardColumnFeaturesProps) {
|
||||||
// Get recently completed features from store for race condition protection
|
// Get recently completed features from store for race condition protection
|
||||||
const recentlyCompletedFeatures = useAppStore((state) => state.recentlyCompletedFeatures);
|
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];
|
map.backlog = [...unblocked, ...blocked];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (sortNewestCardOnTop) {
|
||||||
|
map.backlog = sortNewestWithDependencies(orderedFeatures);
|
||||||
} else {
|
} else {
|
||||||
map.backlog = orderedFeatures;
|
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;
|
return map;
|
||||||
}, [
|
}, [
|
||||||
@@ -289,6 +457,7 @@ export function useBoardColumnFeatures({
|
|||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
projectPath,
|
projectPath,
|
||||||
recentlyCompletedFeatures,
|
recentlyCompletedFeatures,
|
||||||
|
sortNewestCardOnTop,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const getColumnFeatures = useCallback(
|
const getColumnFeatures = useCallback(
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Brain, AlertTriangle } from 'lucide-react';
|
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 { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { getModelProvider } from '@automaker/types';
|
import { getModelProvider } from '@automaker/types';
|
||||||
import type { ModelProvider, CursorModelId } from '@automaker/types';
|
import type { ModelProvider, CursorModelId } from '@automaker/types';
|
||||||
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
|
import { CLAUDE_MODELS, CURSOR_MODELS, OPENCODE_MODELS, ModelOption } from './model-constants';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { useOpencodeModels } from '@/hooks/queries';
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
|
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
|
||||||
@@ -30,9 +31,22 @@ export function ModelSelector({
|
|||||||
codexModelsError,
|
codexModelsError,
|
||||||
fetchCodexModels,
|
fetchCodexModels,
|
||||||
disabledProviders,
|
disabledProviders,
|
||||||
|
enabledOpencodeModels,
|
||||||
|
opencodeDefaultModel,
|
||||||
|
enabledDynamicModelIds,
|
||||||
|
opencodeModelsLoading,
|
||||||
|
fetchOpencodeModels,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const { cursorCliStatus, codexCliStatus } = useSetupStore();
|
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);
|
const selectedProvider = getModelProvider(selectedModel);
|
||||||
|
|
||||||
// Check if Cursor CLI is available
|
// Check if Cursor CLI is available
|
||||||
@@ -49,6 +63,30 @@ export function ModelSelector({
|
|||||||
}
|
}
|
||||||
}, [isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels]);
|
}, [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
|
// Transform codex models from store to ModelOption format
|
||||||
const dynamicCodexModels: ModelOption[] = codexModels.map((model) => {
|
const dynamicCodexModels: ModelOption[] = codexModels.map((model) => {
|
||||||
// Infer badge based on tier
|
// 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
|
// Filter Cursor models based on enabled models from global settings
|
||||||
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
|
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
|
||||||
// enabledCursorModels stores CursorModelIds which may or may not have "cursor-" prefix
|
// enabledCursorModels stores CursorModelIds which may or may not have "cursor-" prefix
|
||||||
@@ -93,6 +161,16 @@ export function ModelSelector({
|
|||||||
} else if (provider === 'claude' && selectedProvider !== 'claude') {
|
} else if (provider === 'claude' && selectedProvider !== 'claude') {
|
||||||
// Switch to Claude's default model (canonical format)
|
// Switch to Claude's default model (canonical format)
|
||||||
onModelSelect('claude-sonnet');
|
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 isClaudeDisabled = disabledProviders.includes('claude');
|
||||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||||
const isCodexDisabled = disabledProviders.includes('codex');
|
const isCodexDisabled = disabledProviders.includes('codex');
|
||||||
|
const isOpencodeDisabled = disabledProviders.includes('opencode');
|
||||||
|
|
||||||
// Count available providers
|
// Count available providers
|
||||||
const availableProviders = [
|
const availableProviders = [
|
||||||
!isClaudeDisabled && 'claude',
|
!isClaudeDisabled && 'claude',
|
||||||
!isCursorDisabled && 'cursor',
|
!isCursorDisabled && 'cursor',
|
||||||
!isCodexDisabled && 'codex',
|
!isCodexDisabled && 'codex',
|
||||||
|
!isOpencodeDisabled && 'opencode',
|
||||||
].filter(Boolean) as ModelProvider[];
|
].filter(Boolean) as ModelProvider[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,6 +243,22 @@ export function ModelSelector({
|
|||||||
Codex CLI
|
Codex CLI
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -384,6 +480,95 @@ export function ModelSelector({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Switch } from '@/components/ui/switch';
|
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 { darkThemes, lightThemes } from '@/config/theme-options';
|
||||||
import {
|
import {
|
||||||
UI_SANS_FONT_OPTIONS,
|
UI_SANS_FONT_OPTIONS,
|
||||||
@@ -28,6 +28,8 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS
|
|||||||
setDisableSplashScreen,
|
setDisableSplashScreen,
|
||||||
sidebarStyle,
|
sidebarStyle,
|
||||||
setSidebarStyle,
|
setSidebarStyle,
|
||||||
|
defaultSortNewestCardOnTop,
|
||||||
|
setDefaultSortNewestCardOnTop,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Determine if current theme is light or dark
|
// Determine if current theme is light or dark
|
||||||
@@ -311,6 +313,31 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export function PhaseModelSelector({
|
|||||||
enabledCursorModels,
|
enabledCursorModels,
|
||||||
enabledGeminiModels,
|
enabledGeminiModels,
|
||||||
enabledCopilotModels,
|
enabledCopilotModels,
|
||||||
|
enabledOpencodeModels,
|
||||||
favoriteModels,
|
favoriteModels,
|
||||||
toggleFavoriteModel,
|
toggleFavoriteModel,
|
||||||
codexModels,
|
codexModels,
|
||||||
@@ -192,6 +193,7 @@ export function PhaseModelSelector({
|
|||||||
enabledCursorModels: state.enabledCursorModels,
|
enabledCursorModels: state.enabledCursorModels,
|
||||||
enabledGeminiModels: state.enabledGeminiModels,
|
enabledGeminiModels: state.enabledGeminiModels,
|
||||||
enabledCopilotModels: state.enabledCopilotModels,
|
enabledCopilotModels: state.enabledCopilotModels,
|
||||||
|
enabledOpencodeModels: state.enabledOpencodeModels,
|
||||||
favoriteModels: state.favoriteModels,
|
favoriteModels: state.favoriteModels,
|
||||||
toggleFavoriteModel: state.toggleFavoriteModel,
|
toggleFavoriteModel: state.toggleFavoriteModel,
|
||||||
codexModels: state.codexModels,
|
codexModels: state.codexModels,
|
||||||
@@ -565,8 +567,10 @@ export function PhaseModelSelector({
|
|||||||
|
|
||||||
// Combine static and dynamic OpenCode models
|
// Combine static and dynamic OpenCode models
|
||||||
const allOpencodeModels: ModelOption[] = useMemo(() => {
|
const allOpencodeModels: ModelOption[] = useMemo(() => {
|
||||||
// Start with static models
|
// Filter static models by what the user has enabled in Settings → AI Providers
|
||||||
const staticModels = [...OPENCODE_MODELS];
|
const staticModels = OPENCODE_MODELS.filter((model) =>
|
||||||
|
(enabledOpencodeModels as string[]).includes(model.id)
|
||||||
|
);
|
||||||
|
|
||||||
// Add dynamic models (convert ModelDefinition to ModelOption)
|
// Add dynamic models (convert ModelDefinition to ModelOption)
|
||||||
// Only include dynamic models that are enabled by the user
|
// Only include dynamic models that are enabled by the user
|
||||||
@@ -580,13 +584,21 @@ export function PhaseModelSelector({
|
|||||||
provider: 'opencode' as const,
|
provider: 'opencode' as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Merge, avoiding duplicates (static models take precedence for same ID)
|
// Merge, avoiding duplicates (static models take precedence)
|
||||||
// In practice, static and dynamic IDs don't overlap
|
// Static IDs use dash format (opencode-glm-5-free), dynamic use slash format (opencode/glm-5-free)
|
||||||
const staticIds = new Set(staticModels.map((m) => m.id));
|
// Normalize both to the model name part for comparison
|
||||||
const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id));
|
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];
|
return [...staticModels, ...uniqueDynamic];
|
||||||
}, [dynamicOpencodeModels, enabledDynamicModelIds]);
|
}, [enabledOpencodeModels, dynamicOpencodeModels, enabledDynamicModelIds]);
|
||||||
|
|
||||||
// Check if providers are disabled (needed for rendering conditions)
|
// Check if providers are disabled (needed for rendering conditions)
|
||||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
|||||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
|
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
|
||||||
muteDoneSound: state.muteDoneSound as boolean,
|
muteDoneSound: state.muteDoneSound as boolean,
|
||||||
disableSplashScreen: state.disableSplashScreen as boolean,
|
disableSplashScreen: state.disableSplashScreen as boolean,
|
||||||
|
defaultSortNewestCardOnTop: state.defaultSortNewestCardOnTop as boolean,
|
||||||
enhancementModel: state.enhancementModel as GlobalSettings['enhancementModel'],
|
enhancementModel: state.enhancementModel as GlobalSettings['enhancementModel'],
|
||||||
validationModel: state.validationModel as GlobalSettings['validationModel'],
|
validationModel: state.validationModel as GlobalSettings['validationModel'],
|
||||||
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
|
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
|
||||||
@@ -685,6 +686,12 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
(modelId) => !modelId.startsWith('amazon-bedrock/')
|
(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)
|
// Convert ProjectRef[] to Project[] (minimal data, features will be loaded separately)
|
||||||
const projects = (settings.projects ?? []).map((ref) => ({
|
const projects = (settings.projects ?? []).map((ref) => ({
|
||||||
id: ref.id,
|
id: ref.id,
|
||||||
@@ -764,6 +771,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
},
|
},
|
||||||
muteDoneSound: settings.muteDoneSound ?? false,
|
muteDoneSound: settings.muteDoneSound ?? false,
|
||||||
disableSplashScreen: settings.disableSplashScreen ?? false,
|
disableSplashScreen: settings.disableSplashScreen ?? false,
|
||||||
|
defaultSortNewestCardOnTop: settings.defaultSortNewestCardOnTop ?? false,
|
||||||
serverLogLevel: settings.serverLogLevel ?? 'info',
|
serverLogLevel: settings.serverLogLevel ?? 'info',
|
||||||
enableRequestLogging: settings.enableRequestLogging ?? true,
|
enableRequestLogging: settings.enableRequestLogging ?? true,
|
||||||
showQueryDevtools: settings.showQueryDevtools ?? true,
|
showQueryDevtools: settings.showQueryDevtools ?? true,
|
||||||
@@ -777,6 +785,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||||
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||||
|
knownDynamicModelIds: sanitizedKnownDynamicModelIds,
|
||||||
disabledProviders: settings.disabledProviders ?? [],
|
disabledProviders: settings.disabledProviders ?? [],
|
||||||
enableAiCommitMessages: settings.enableAiCommitMessages ?? true,
|
enableAiCommitMessages: settings.enableAiCommitMessages ?? true,
|
||||||
enableSkills: settings.enableSkills ?? true,
|
enableSkills: settings.enableSkills ?? true,
|
||||||
@@ -906,6 +915,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
|||||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||||
muteDoneSound: state.muteDoneSound,
|
muteDoneSound: state.muteDoneSound,
|
||||||
disableSplashScreen: state.disableSplashScreen,
|
disableSplashScreen: state.disableSplashScreen,
|
||||||
|
defaultSortNewestCardOnTop: state.defaultSortNewestCardOnTop,
|
||||||
serverLogLevel: state.serverLogLevel,
|
serverLogLevel: state.serverLogLevel,
|
||||||
enableRequestLogging: state.enableRequestLogging,
|
enableRequestLogging: state.enableRequestLogging,
|
||||||
enhancementModel: state.enhancementModel,
|
enhancementModel: state.enhancementModel,
|
||||||
@@ -914,6 +924,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
|||||||
defaultThinkingLevel: state.defaultThinkingLevel,
|
defaultThinkingLevel: state.defaultThinkingLevel,
|
||||||
defaultReasoningEffort: state.defaultReasoningEffort,
|
defaultReasoningEffort: state.defaultReasoningEffort,
|
||||||
enabledDynamicModelIds: state.enabledDynamicModelIds,
|
enabledDynamicModelIds: state.enabledDynamicModelIds,
|
||||||
|
knownDynamicModelIds: state.knownDynamicModelIds,
|
||||||
disabledProviders: state.disabledProviders,
|
disabledProviders: state.disabledProviders,
|
||||||
enableAiCommitMessages: state.enableAiCommitMessages,
|
enableAiCommitMessages: state.enableAiCommitMessages,
|
||||||
enableSkills: state.enableSkills,
|
enableSkills: state.enableSkills,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'defaultFeatureModel',
|
'defaultFeatureModel',
|
||||||
'muteDoneSound',
|
'muteDoneSound',
|
||||||
'disableSplashScreen',
|
'disableSplashScreen',
|
||||||
|
'defaultSortNewestCardOnTop',
|
||||||
'serverLogLevel',
|
'serverLogLevel',
|
||||||
'enableRequestLogging',
|
'enableRequestLogging',
|
||||||
'showQueryDevtools',
|
'showQueryDevtools',
|
||||||
@@ -86,6 +87,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'enabledCopilotModels',
|
'enabledCopilotModels',
|
||||||
'copilotDefaultModel',
|
'copilotDefaultModel',
|
||||||
'enabledDynamicModelIds',
|
'enabledDynamicModelIds',
|
||||||
|
'knownDynamicModelIds',
|
||||||
'disabledProviders',
|
'disabledProviders',
|
||||||
'autoLoadClaudeMd',
|
'autoLoadClaudeMd',
|
||||||
'useClaudeCodeSystemPrompt',
|
'useClaudeCodeSystemPrompt',
|
||||||
@@ -482,6 +484,14 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
return;
|
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.
|
// If projects array changed *meaningfully*, sync immediately.
|
||||||
// This is critical — projects list changes must sync right away to prevent loss
|
// This is critical — projects list changes must sync right away to prevent loss
|
||||||
// when switching between Electron and web modes or closing the app.
|
// 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/')
|
(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
|
// Migrate phase models to canonical format
|
||||||
const migratedPhaseModels = serverSettings.phaseModels
|
const migratedPhaseModels = serverSettings.phaseModels
|
||||||
? {
|
? {
|
||||||
@@ -788,6 +804,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
muteDoneSound: serverSettings.muteDoneSound,
|
muteDoneSound: serverSettings.muteDoneSound,
|
||||||
defaultMaxTurns: serverSettings.defaultMaxTurns ?? 10000,
|
defaultMaxTurns: serverSettings.defaultMaxTurns ?? 10000,
|
||||||
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
|
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
|
||||||
|
defaultSortNewestCardOnTop: serverSettings.defaultSortNewestCardOnTop ?? false,
|
||||||
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
||||||
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
||||||
enhancementModel: serverSettings.enhancementModel,
|
enhancementModel: serverSettings.enhancementModel,
|
||||||
@@ -807,6 +824,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
enabledCopilotModels: sanitizedEnabledCopilotModels,
|
enabledCopilotModels: sanitizedEnabledCopilotModels,
|
||||||
copilotDefaultModel: sanitizedCopilotDefaultModel,
|
copilotDefaultModel: sanitizedCopilotDefaultModel,
|
||||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||||
|
knownDynamicModelIds: sanitizedKnownDynamicModelIds,
|
||||||
disabledProviders: serverSettings.disabledProviders ?? [],
|
disabledProviders: serverSettings.disabledProviders ?? [],
|
||||||
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? true,
|
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? true,
|
||||||
useClaudeCodeSystemPrompt: serverSettings.useClaudeCodeSystemPrompt ?? true,
|
useClaudeCodeSystemPrompt: serverSettings.useClaudeCodeSystemPrompt ?? true,
|
||||||
|
|||||||
@@ -93,6 +93,35 @@ export function formatModelName(model: string, options?: FormatModelNameOptions)
|
|||||||
return model.replace('cursor-', 'Cursor ').replace('gemini', 'Gemini');
|
return model.replace('cursor-', 'Cursor ').replace('gemini', 'Gemini');
|
||||||
if (model.startsWith('cursor-grok')) return 'Cursor Grok';
|
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
|
// Default: split by dash and capitalize
|
||||||
return model.split('-').slice(1, 3).join(' ');
|
return model.split('-').slice(1, 3).join(' ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ const initialState: AppState = {
|
|||||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
||||||
muteDoneSound: false,
|
muteDoneSound: false,
|
||||||
disableSplashScreen: false,
|
disableSplashScreen: false,
|
||||||
|
defaultSortNewestCardOnTop: false,
|
||||||
serverLogLevel: 'info',
|
serverLogLevel: 'info',
|
||||||
enableRequestLogging: true,
|
enableRequestLogging: true,
|
||||||
showQueryDevtools: true,
|
showQueryDevtools: true,
|
||||||
@@ -333,6 +334,7 @@ const initialState: AppState = {
|
|||||||
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
|
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
|
||||||
dynamicOpencodeModels: [],
|
dynamicOpencodeModels: [],
|
||||||
enabledDynamicModelIds: [],
|
enabledDynamicModelIds: [],
|
||||||
|
knownDynamicModelIds: [],
|
||||||
cachedOpencodeProviders: [],
|
cachedOpencodeProviders: [],
|
||||||
opencodeModelsLoading: false,
|
opencodeModelsLoading: false,
|
||||||
opencodeModelsError: null,
|
opencodeModelsError: null,
|
||||||
@@ -1233,6 +1235,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Splash Screen actions
|
// Splash Screen actions
|
||||||
setDisableSplashScreen: (disabled) => set({ disableSplashScreen: disabled }),
|
setDisableSplashScreen: (disabled) => set({ disableSplashScreen: disabled }),
|
||||||
|
|
||||||
|
// Board Card Sorting (global default) actions
|
||||||
|
setDefaultSortNewestCardOnTop: (enabled) => set({ defaultSortNewestCardOnTop: enabled }),
|
||||||
|
|
||||||
// Server Log Level actions
|
// Server Log Level actions
|
||||||
setServerLogLevel: (level) => set({ serverLogLevel: level }),
|
setServerLogLevel: (level) => set({ serverLogLevel: level }),
|
||||||
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
|
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
|
||||||
@@ -1355,21 +1360,52 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
|
|
||||||
// OpenCode CLI Settings actions
|
// OpenCode CLI Settings actions
|
||||||
setEnabledOpencodeModels: (models) => set({ enabledOpencodeModels: models }),
|
setEnabledOpencodeModels: (models) => set({ enabledOpencodeModels: models }),
|
||||||
setOpencodeDefaultModel: (model) => set({ opencodeDefaultModel: model }),
|
setOpencodeDefaultModel: async (model) => {
|
||||||
toggleOpencodeModel: (model, enabled) =>
|
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) => ({
|
set((state) => ({
|
||||||
enabledOpencodeModels: enabled
|
enabledOpencodeModels: enabled
|
||||||
? [...state.enabledOpencodeModels, model]
|
? [...new Set([...state.enabledOpencodeModels, model])]
|
||||||
: state.enabledOpencodeModels.filter((m) => m !== 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 }),
|
setDynamicOpencodeModels: (models) => set({ dynamicOpencodeModels: models }),
|
||||||
setEnabledDynamicModelIds: (ids) => set({ enabledDynamicModelIds: ids }),
|
setEnabledDynamicModelIds: async (ids) => {
|
||||||
toggleDynamicModel: (modelId, enabled) =>
|
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) => ({
|
set((state) => ({
|
||||||
enabledDynamicModelIds: enabled
|
enabledDynamicModelIds: enabled
|
||||||
? [...state.enabledDynamicModelIds, modelId]
|
? [...new Set([...state.enabledDynamicModelIds, modelId])]
|
||||||
: state.enabledDynamicModelIds.filter((id) => id !== 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 }),
|
setCachedOpencodeProviders: (providers) => set({ cachedOpencodeProviders: providers }),
|
||||||
|
|
||||||
// Gemini CLI Settings actions
|
// Gemini CLI Settings actions
|
||||||
@@ -2877,13 +2913,43 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
(m) => !m.id.startsWith(OPENCODE_BEDROCK_MODEL_PREFIX)
|
(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({
|
set({
|
||||||
dynamicOpencodeModels: filteredModels,
|
dynamicOpencodeModels: filteredModels,
|
||||||
|
enabledDynamicModelIds: updatedEnabledIds,
|
||||||
|
knownDynamicModelIds: updatedKnownIds,
|
||||||
cachedOpencodeProviders: data.providers ?? [],
|
cachedOpencodeProviders: data.providers ?? [],
|
||||||
opencodeModelsLoading: false,
|
opencodeModelsLoading: false,
|
||||||
opencodeModelsLastFetched: now,
|
opencodeModelsLastFetched: now,
|
||||||
opencodeModelsError: null,
|
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 {
|
} else {
|
||||||
set({
|
set({
|
||||||
opencodeModelsLoading: false,
|
opencodeModelsLoading: false,
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ export interface AppState {
|
|||||||
// Splash Screen Settings
|
// Splash Screen Settings
|
||||||
disableSplashScreen: boolean; // When true, skip showing the splash screen overlay on startup
|
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
|
// Server Log Level Settings
|
||||||
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
|
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
|
||||||
enableRequestLogging: boolean; // Enable HTTP request logging (Morgan)
|
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
|
// from `opencode models` CLI and depend on current provider authentication state
|
||||||
dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI
|
dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI
|
||||||
enabledDynamicModelIds: string[]; // Which dynamic models are enabled
|
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<{
|
cachedOpencodeProviders: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -574,6 +578,9 @@ export interface AppActions {
|
|||||||
// Splash Screen actions
|
// Splash Screen actions
|
||||||
setDisableSplashScreen: (disabled: boolean) => void;
|
setDisableSplashScreen: (disabled: boolean) => void;
|
||||||
|
|
||||||
|
// Board Card Sorting (global default) actions
|
||||||
|
setDefaultSortNewestCardOnTop: (enabled: boolean) => void;
|
||||||
|
|
||||||
// Server Log Level actions
|
// Server Log Level actions
|
||||||
setServerLogLevel: (level: ServerLogLevel) => void;
|
setServerLogLevel: (level: ServerLogLevel) => void;
|
||||||
setEnableRequestLogging: (enabled: boolean) => void;
|
setEnableRequestLogging: (enabled: boolean) => void;
|
||||||
@@ -616,12 +623,16 @@ export interface AppActions {
|
|||||||
setCodexEnableImages: (enabled: boolean) => Promise<void>;
|
setCodexEnableImages: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
// OpenCode CLI Settings actions
|
// 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;
|
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
|
||||||
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
|
setOpencodeDefaultModel: (model: OpencodeModelId) => Promise<void>;
|
||||||
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => void;
|
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => Promise<void>;
|
||||||
setDynamicOpencodeModels: (models: ModelDefinition[]) => void;
|
setDynamicOpencodeModels: (models: ModelDefinition[]) => void;
|
||||||
setEnabledDynamicModelIds: (ids: string[]) => void;
|
setEnabledDynamicModelIds: (ids: string[]) => Promise<void>;
|
||||||
toggleDynamicModel: (modelId: string, enabled: boolean) => void;
|
toggleDynamicModel: (modelId: string, enabled: boolean) => Promise<void>;
|
||||||
setCachedOpencodeProviders: (
|
setCachedOpencodeProviders: (
|
||||||
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
|
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
|
||||||
) => void;
|
) => void;
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export type EventType =
|
|||||||
| 'switch:pop'
|
| 'switch:pop'
|
||||||
| 'switch:done'
|
| 'switch:done'
|
||||||
| 'switch:error'
|
| 'switch:error'
|
||||||
| 'notification:created';
|
| 'notification:created'
|
||||||
|
| 'worktree:deleted'
|
||||||
|
| 'feature:migrated';
|
||||||
|
|
||||||
export type EventCallback = (type: EventType, payload: unknown) => void;
|
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export interface Feature {
|
|||||||
planSpec?: PlanSpec;
|
planSpec?: PlanSpec;
|
||||||
error?: string;
|
error?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
createdAt?: string; // ISO timestamp when feature was created
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
descriptionHistory?: DescriptionHistoryEntry[]; // History of description changes
|
descriptionHistory?: DescriptionHistoryEntry[]; // History of description changes
|
||||||
[key: string]: unknown; // Keep catch-all for extensibility
|
[key: string]: unknown; // Keep catch-all for extensibility
|
||||||
|
|||||||
@@ -1198,6 +1198,10 @@ export interface GlobalSettings {
|
|||||||
/** Disable the splash screen overlay on app startup */
|
/** Disable the splash screen overlay on app startup */
|
||||||
disableSplashScreen: boolean;
|
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
|
// Server Logging Preferences
|
||||||
/** Log level for the API server (error, warn, info, debug). Default: info */
|
/** Log level for the API server (error, warn, info, debug). Default: info */
|
||||||
serverLogLevel?: ServerLogLevel;
|
serverLogLevel?: ServerLogLevel;
|
||||||
@@ -1253,6 +1257,8 @@ export interface GlobalSettings {
|
|||||||
opencodeDefaultModel?: OpencodeModelId;
|
opencodeDefaultModel?: OpencodeModelId;
|
||||||
/** Which dynamic OpenCode models are enabled (empty = all discovered) */
|
/** Which dynamic OpenCode models are enabled (empty = all discovered) */
|
||||||
enabledDynamicModelIds?: string[];
|
enabledDynamicModelIds?: string[];
|
||||||
|
/** All dynamic model IDs ever seen - used to distinguish new models from explicitly deselected ones */
|
||||||
|
knownDynamicModelIds?: string[];
|
||||||
|
|
||||||
// Gemini CLI Settings (global)
|
// Gemini CLI Settings (global)
|
||||||
/** Which Gemini models are available in feature modal (empty = all) */
|
/** 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
|
defaultFeatureModel: { model: 'claude-opus', thinkingLevel: 'adaptive' }, // Use canonical ID with adaptive thinking
|
||||||
muteDoneSound: false,
|
muteDoneSound: false,
|
||||||
disableSplashScreen: false,
|
disableSplashScreen: false,
|
||||||
|
defaultSortNewestCardOnTop: false,
|
||||||
serverLogLevel: 'info',
|
serverLogLevel: 'info',
|
||||||
enableRequestLogging: true,
|
enableRequestLogging: true,
|
||||||
showQueryDevtools: true,
|
showQueryDevtools: true,
|
||||||
@@ -1780,6 +1787,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
enabledOpencodeModels: getAllOpencodeModelIds(), // Returns prefixed IDs
|
enabledOpencodeModels: getAllOpencodeModelIds(), // Returns prefixed IDs
|
||||||
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Already prefixed
|
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Already prefixed
|
||||||
enabledDynamicModelIds: [],
|
enabledDynamicModelIds: [],
|
||||||
|
knownDynamicModelIds: [],
|
||||||
enabledGeminiModels: getAllGeminiModelIds(), // Returns prefixed IDs
|
enabledGeminiModels: getAllGeminiModelIds(), // Returns prefixed IDs
|
||||||
geminiDefaultModel: DEFAULT_GEMINI_MODEL, // Already prefixed
|
geminiDefaultModel: DEFAULT_GEMINI_MODEL, // Already prefixed
|
||||||
enabledCopilotModels: getAllCopilotModelIds(), // Returns prefixed IDs
|
enabledCopilotModels: getAllCopilotModelIds(), // Returns prefixed IDs
|
||||||
|
|||||||
Reference in New Issue
Block a user