diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index 735d8812..d4406766 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -10,6 +10,7 @@ import { createCreateHandler } from "./routes/create.js"; import { createUpdateHandler } from "./routes/update.js"; import { createDeleteHandler } from "./routes/delete.js"; import { createAgentOutputHandler } from "./routes/agent-output.js"; +import { createGenerateTitleHandler } from "./routes/generate-title.js"; export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { const router = Router(); @@ -20,6 +21,7 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { router.post("/update", createUpdateHandler(featureLoader)); router.post("/delete", createDeleteHandler(featureLoader)); router.post("/agent-output", createAgentOutputHandler(featureLoader)); + router.post("/generate-title", createGenerateTitleHandler()); return router; } diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts new file mode 100644 index 00000000..8781a8b2 --- /dev/null +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -0,0 +1,137 @@ +/** + * POST /features/generate-title endpoint - Generate a concise title from description + * + * Uses Claude Haiku to generate a short, descriptive title from feature description. + */ + +import type { Request, Response } from "express"; +import { query } from "@anthropic-ai/claude-agent-sdk"; +import { createLogger } from "../../../lib/logger.js"; +import { CLAUDE_MODEL_MAP } from "../../../lib/model-resolver.js"; + +const logger = createLogger("GenerateTitle"); + +interface GenerateTitleRequestBody { + description: string; +} + +interface GenerateTitleSuccessResponse { + success: true; + title: string; +} + +interface GenerateTitleErrorResponse { + success: false; + error: string; +} + +const SYSTEM_PROMPT = `You are a title generator. Your task is to create a concise, descriptive title (5-10 words max) for a software feature based on its description. + +Rules: +- Output ONLY the title, nothing else +- Keep it short and action-oriented (e.g., "Add dark mode toggle", "Fix login validation") +- Start with a verb when possible (Add, Fix, Update, Implement, Create, etc.) +- No quotes, periods, or extra formatting +- Capture the essence of the feature in a scannable way`; + +async function extractTextFromStream( + stream: AsyncIterable<{ + type: string; + subtype?: string; + result?: string; + message?: { + content?: Array<{ type: string; text?: string }>; + }; + }> +): Promise { + let responseText = ""; + + for await (const msg of stream) { + if (msg.type === "assistant" && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === "text" && block.text) { + responseText += block.text; + } + } + } else if (msg.type === "result" && msg.subtype === "success") { + responseText = msg.result || responseText; + } + } + + return responseText; +} + +export function createGenerateTitleHandler(): ( + req: Request, + res: Response +) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { description } = req.body as GenerateTitleRequestBody; + + if (!description || typeof description !== "string") { + const response: GenerateTitleErrorResponse = { + success: false, + error: "description is required and must be a string", + }; + res.status(400).json(response); + return; + } + + const trimmedDescription = description.trim(); + if (trimmedDescription.length === 0) { + const response: GenerateTitleErrorResponse = { + success: false, + error: "description cannot be empty", + }; + res.status(400).json(response); + return; + } + + logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`); + + const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; + + const stream = query({ + prompt: userPrompt, + options: { + model: CLAUDE_MODEL_MAP.haiku, + systemPrompt: SYSTEM_PROMPT, + maxTurns: 1, + allowedTools: [], + permissionMode: "acceptEdits", + }, + }); + + const title = await extractTextFromStream(stream); + + if (!title || title.trim().length === 0) { + logger.warn("Received empty response from Claude"); + const response: GenerateTitleErrorResponse = { + success: false, + error: "Failed to generate title - empty response", + }; + res.status(500).json(response); + return; + } + + logger.info(`Generated title: ${title.trim()}`); + + const response: GenerateTitleSuccessResponse = { + success: true, + title: title.trim(), + }; + res.json(response); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred"; + logger.error("Title generation failed:", errorMessage); + + const response: GenerateTitleErrorResponse = { + success: false, + error: errorMessage, + }; + res.status(500).json(response); + } + }; +} diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 42fabbb2..9b812642 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -14,6 +14,8 @@ import { export interface Feature { id: string; + title?: string; + titleGenerating?: boolean; category: string; description: string; steps?: string[]; diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index b33c95d2..c643a827 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -466,6 +466,47 @@ export function BoardView() { [handleAddFeature, handleStartImplementation, defaultSkipTests] ); + // Handler for resolving conflicts - creates a feature to pull from origin/main and resolve conflicts + const handleResolveConflicts = useCallback( + async (worktree: WorktreeInfo) => { + const description = `Pull latest from origin/main and resolve conflicts. Merge origin/main into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`; + + // Create the feature + const featureData = { + category: "Maintenance", + description, + steps: [], + images: [], + imagePaths: [], + skipTests: defaultSkipTests, + model: "opus" as const, + thinkingLevel: "none" as const, + branchName: worktree.branch, + priority: 1, // High priority for conflict resolution + planningMode: "skip" as const, + requirePlanApproval: false, + }; + + await handleAddFeature(featureData); + + // Find the newly created feature and start it + setTimeout(async () => { + const latestFeatures = useAppStore.getState().features; + const newFeature = latestFeatures.find( + (f) => + f.branchName === worktree.branch && + f.status === "backlog" && + f.description.includes("Pull latest from origin/main") + ); + + if (newFeature) { + await handleStartImplementation(newFeature); + } + }, FEATURE_CREATION_SETTLE_DELAY_MS); + }, + [handleAddFeature, handleStartImplementation, defaultSkipTests] + ); + // Client-side auto mode: periodically check for backlog items and move them to in-progress // Use a ref to track the latest auto mode state so async operations always check the current value const autoModeRunningRef = useRef(autoMode.isRunning); @@ -886,6 +927,7 @@ export function BoardView() { { @@ -926,6 +968,7 @@ export function BoardView() { setShowCreateBranchDialog(true); }} onAddressPRComments={handleAddressPRComments} + onResolveConflicts={handleResolveConflicts} onRemovedWorktrees={handleRemovedWorktrees} runningFeatureIds={runningAutoTasks} branchCardCounts={branchCardCounts} diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index c21a3233..b70c615d 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -4,12 +4,13 @@ import { HotkeyButton } from "@/components/ui/hotkey-button"; import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -import { Plus, Users } from "lucide-react"; +import { Plus, Bot } from "lucide-react"; import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts"; interface BoardHeaderProps { projectName: string; maxConcurrency: number; + runningAgentsCount: number; onConcurrencyChange: (value: number) => void; isAutoModeRunning: boolean; onAutoModeToggle: (enabled: boolean) => void; @@ -21,6 +22,7 @@ interface BoardHeaderProps { export function BoardHeader({ projectName, maxConcurrency, + runningAgentsCount, onConcurrencyChange, isAutoModeRunning, onAutoModeToggle, @@ -41,7 +43,8 @@ export function BoardHeader({ className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border" data-testid="concurrency-slider-container" > - + + Agents onConcurrencyChange(value[0])} @@ -52,10 +55,10 @@ export function BoardHeader({ data-testid="concurrency-slider" /> - {maxConcurrency} + {runningAgentsCount} / {maxConcurrency} )} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card.tsx index 96ccb618..9b31771e 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card.tsx @@ -647,14 +647,24 @@ export const KanbanCard = memo(function KanbanCard({ )}
- + + Generating title... +
+ ) : feature.title ? ( + + {feature.title} + + ) : null} + {feature.description || feature.summary || feature.id} - + {(feature.description || feature.summary || "").length > 100 && (