Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ app.use(
);
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService, featureLoader));
app.use('/api/git', createGitRoutes());
app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
Expand Down
22 changes: 20 additions & 2 deletions apps/server/src/providers/opencode-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1189,8 +1189,26 @@ export class OpencodeProvider extends CliProvider {
* Format a display name for a model
*/
private formatModelDisplayName(model: OpenCodeModelInfo): string {
// Extract the last path segment for nested model IDs
// e.g., "arcee-ai/trinity-large-preview:free" → "trinity-large-preview:free"
let rawName = model.name;
if (rawName.includes('/')) {
rawName = rawName.split('/').pop()!;
}

// Strip tier/pricing suffixes like ":free", ":extended"
const colonIdx = rawName.indexOf(':');
let suffix = '';
if (colonIdx !== -1) {
const tierPart = rawName.slice(colonIdx + 1);
if (/^(free|extended|beta|preview)$/i.test(tierPart)) {
suffix = ` (${tierPart.charAt(0).toUpperCase() + tierPart.slice(1)})`;
}
rawName = rawName.slice(0, colonIdx);
}

// Capitalize and format the model name
const formattedName = model.name
const formattedName = rawName
.split('-')
.map((part) => {
// Handle version numbers like "4-5" -> "4.5"
Expand Down Expand Up @@ -1218,7 +1236,7 @@ export class OpencodeProvider extends CliProvider {
};

const providerDisplay = providerNames[model.provider] || model.provider;
return `${formattedName} (${providerDisplay})`;
return `${formattedName}${suffix} (${providerDisplay})`;
}

/**
Expand Down
10 changes: 8 additions & 2 deletions apps/server/src/routes/worktree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,12 @@ import { createSetTrackingHandler } from './routes/set-tracking.js';
import { createSyncHandler } from './routes/sync.js';
import { createUpdatePRNumberHandler } from './routes/update-pr-number.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { FeatureLoader } from '../../services/feature-loader.js';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a shared @automaker/* import for FeatureLoader.

This newly added relative import conflicts with the TS/JS import rule.

As per coding guidelines **/*.{ts,tsx,js,jsx}: Always import from shared packages (@automaker/*), never from old paths like '../services/feature-loader' or '../lib/logger'.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/routes/worktree/index.ts` at line 74, Replace the relative
import of FeatureLoader with the shared package import as per guidelines: change
the import that currently reads import type { FeatureLoader } from
'../../services/feature-loader.js' to import type { FeatureLoader } from
'@automaker/feature-loader' (or the canonical `@automaker` package that exports
FeatureLoader). Ensure you only change the import statement and keep the rest of
the code using the FeatureLoader type unchanged.


export function createWorktreeRoutes(
events: EventEmitter,
settingsService?: SettingsService
settingsService?: SettingsService,
featureLoader?: FeatureLoader
): Router {
const router = Router();

Expand All @@ -94,7 +96,11 @@ export function createWorktreeRoutes(
validatePathParams('projectPath'),
createCreateHandler(events, settingsService)
);
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post(
'/delete',
validatePathParams('projectPath', 'worktreePath'),
createDeleteHandler(events, featureLoader)
);
Comment on lines +99 to +103
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Forward events to delete handler so this operation can emit WebSocket events.

The delete flow now mutates feature state, but this route wiring only passes featureLoader, so the handler cannot emit operation events through the shared emitter.

💡 Suggested wiring update
   router.post(
     '/delete',
     validatePathParams('projectPath', 'worktreePath'),
-    createDeleteHandler(featureLoader)
+    createDeleteHandler(events, featureLoader)
   );
// Companion update in apps/server/src/routes/worktree/routes/delete.ts
export function createDeleteHandler(events: EventEmitter, featureLoader?: FeatureLoader) { ... }

As per coding guidelines apps/server/src/**/*.{ts,js}: Use createEventEmitter() from lib/events.ts for all server operations to emit events that stream to the frontend via WebSocket.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/routes/worktree/index.ts` around lines 99 - 103, The route
wiring for the delete endpoint must forward the shared EventEmitter so the
handler can emit websocket events: update the router.post call that currently
passes only featureLoader to call createDeleteHandler with the EventEmitter
instance (use createEventEmitter() from lib/events.ts per guideline) alongside
featureLoader; ensure you import/create the emitter in this module and pass that
emitter into createDeleteHandler (which should match the updated signature
createDeleteHandler(events: EventEmitter, featureLoader?: FeatureLoader)),
leaving validatePathParams('projectPath','worktreePath') unchanged.

router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
router.post(
Expand Down
57 changes: 56 additions & 1 deletion apps/server/src/routes/worktree/routes/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { createLogger } from '@automaker/utils';
import type { FeatureLoader } from '../../../services/feature-loader.js';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a shared @automaker/* import for FeatureLoader.

This new relative service import violates the repository import rule for TS/JS files.

As per coding guidelines **/*.{ts,tsx,js,jsx}: Always import from shared packages (@automaker/*), never from old paths like '../services/feature-loader' or '../lib/logger'.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/routes/worktree/routes/delete.ts` at line 13, Replace the
relative import of the FeatureLoader type with the shared `@automaker` package
import: update the import statement that currently reads "import type {
FeatureLoader } from '../../../services/feature-loader.js';" to import the same
symbol from the appropriate `@automaker` package (e.g. "import type {
FeatureLoader } from '@automaker/feature-loader'"), ensuring the FeatureLoader
type is referenced from the shared package rather than a relative path.

import type { EventEmitter } from '../../../lib/events.js';

const execAsync = promisify(exec);
const logger = createLogger('Worktree');

export function createDeleteHandler() {
export function createDeleteHandler(events: EventEmitter, featureLoader?: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath, deleteBranch } = req.body as {
Expand Down Expand Up @@ -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,
});
}
}
Comment on lines +154 to +176
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The feature updates are currently performed sequentially in a for...of loop. For a branch with many associated features, this could be slow as each update waits for the previous one to complete. You can improve performance by running these updates in parallel using Promise.all.

            await Promise.all(
              affectedFeatures.map((feature) =>
                featureLoader.update(projectPath, feature.id, {
                  branchName: null,
                })
              )
            );

if (featuresMovedToMain > 0) {
logger.info(
`Moved ${featuresMovedToMain} feature(s) to main worktree after deleting worktree with branch: ${branchName}`
);
}
} catch (featureError) {
// Non-fatal: log but don't fail the deletion (getAll failed)
logger.warn('Failed to load features for migration to main worktree after deletion', {
error: getErrorMessage(featureError),
branchName,
});
}
}

res.json({
success: true,
deleted: {
worktreePath,
branch: branchDeleted ? branchName : null,
branchDeleted,
featuresMovedToMain,
},
});
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/services/feature-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ export class FeatureLoader {
description: featureData.description || '',
...featureData,
id: featureId,
createdAt: featureData.createdAt || new Date().toISOString(),
imagePaths: migratedImagePaths,
descriptionHistory: initialHistory,
};
Expand Down
7 changes: 6 additions & 1 deletion apps/ui/src/components/dialogs/board-background-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
<SheetHeader className="px-6 pt-6">
<SheetHeader
className="px-6"
style={{
paddingTop: 'max(1.5rem, calc(env(safe-area-inset-top, 0px) + 1rem))',
}}
>
<SheetTitle className="flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-brand-500" />
Board Background Settings
Expand Down
10 changes: 9 additions & 1 deletion apps/ui/src/components/ui/sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const SheetContent = ({ className, children, side = 'right', ...props }: SheetCo
const Close = SheetPrimitive.Close as React.ComponentType<{
className: string;
children: React.ReactNode;
'data-slot'?: string;
style?: React.CSSProperties;
}>;

return (
Expand All @@ -79,7 +81,13 @@ const SheetContent = ({ className, children, side = 'right', ...props }: SheetCo
{...props}
>
{children}
<Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<Close
data-slot="sheet-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
style={{
top: 'max(1rem, calc(env(safe-area-inset-top, 0px) + 0.5rem))',
}}
>
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</Close>
Expand Down
27 changes: 14 additions & 13 deletions apps/ui/src/components/views/agent-view/components/agent-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,32 +27,32 @@ export function AgentHeader({
worktreeBranch,
}: AgentHeaderProps) {
return (
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
<div className="flex items-center gap-4">
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<div className="flex items-center justify-between gap-2 px-3 py-3 sm:px-6 sm:py-4 border-b border-border bg-card/50 backdrop-blur-sm">
<div className="flex items-center gap-2 sm:gap-4 min-w-0">
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
<Bot className="w-5 h-5 text-primary" />
</div>
<div>
<div className="min-w-0">
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<span className="truncate">
{projectName}
{currentSessionId && !isConnected && ' - Connecting...'}
</span>
{worktreeBranch && (
<span className="inline-flex items-center gap-1 text-xs bg-muted/50 px-2 py-0.5 rounded-full border border-border">
<span className="inline-flex items-center gap-1 text-xs bg-muted/50 px-2 py-0.5 rounded-full border border-border shrink-0">
<GitBranch className="w-3 h-3 shrink-0" />
<span className="max-w-[180px] truncate">{worktreeBranch}</span>
<span className="max-w-[100px] sm:max-w-[180px] truncate">{worktreeBranch}</span>
</span>
)}
</div>
</div>
</div>

{/* Status indicators & actions */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1 sm:gap-3 shrink-0">
{currentTool && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
<div className="hidden sm:flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
<Wrench className="w-3 h-3 text-primary" />
<span className="font-medium">{currentTool}</span>
</div>
Expand All @@ -63,10 +63,11 @@ export function AgentHeader({
size="sm"
onClick={onClearChat}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground"
aria-label="Clear chat"
className="text-muted-foreground hover:text-foreground h-8 w-8 p-0 sm:w-auto sm:px-3"
>
<Trash2 className="w-4 h-4 mr-2" />
Clear
<Trash2 className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Clear</span>
</Button>
)}
<Button
Expand Down
14 changes: 9 additions & 5 deletions apps/ui/src/components/views/board-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export function BoardView() {
getPrimaryWorktreeBranch,
setPipelineConfig,
featureTemplates,
defaultSortNewestCardOnTop,
} = useAppStore(
useShallow((state) => ({
currentProject: state.currentProject,
Expand All @@ -152,6 +153,7 @@ export function BoardView() {
getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch,
setPipelineConfig: state.setPipelineConfig,
featureTemplates: state.featureTemplates,
defaultSortNewestCardOnTop: state.defaultSortNewestCardOnTop,
}))
);
// Also get keyboard shortcuts for the add feature shortcut
Expand Down Expand Up @@ -1458,6 +1460,11 @@ export function BoardView() {
]
);

// Use background hook for visual settings (background image, opacity, etc.)
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
currentProject,
});

// Use column features hook
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
features: hookFeatures,
Expand All @@ -1467,6 +1474,7 @@ export function BoardView() {
currentWorktreePath,
currentWorktreeBranch,
projectPath: currentProject?.path || null,
sortNewestCardOnTop: defaultSortNewestCardOnTop,
});

// Build columnFeaturesMap for ListView
Expand All @@ -1480,11 +1488,6 @@ export function BoardView() {
return map;
}, [pipelineConfig, getColumnFeatures]);

// Use background hook
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
currentProject,
});

// Find feature for pending plan approval
const pendingApprovalFeature = useMemo(() => {
if (!pendingPlanApproval) return null;
Expand Down Expand Up @@ -1802,6 +1805,7 @@ export function BoardView() {
handleViewOutput(feature);
}
}}
sortNewestCardOnTop={defaultSortNewestCardOnTop}
className="transition-opacity duration-200"
/>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
)}
{effectiveTodos.length > 3 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsTodosExpanded(!isTodosExpanded);
Expand All @@ -481,11 +482,22 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
{effectiveSummary && (
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSummaryDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0 hover:opacity-80 transition-opacity cursor-pointer"
title="View full summary"
>
<Sparkles className="w-3 h-3 shrink-0" />
<span className="truncate font-medium">Summary</span>
</div>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSummaryDialogOpen(true);
Expand Down
Loading
Loading