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/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 9a7950e3..4dc52c72 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -1029,12 +1029,6 @@ export function Sidebar() { icon: UserCircle, shortcut: shortcuts.profiles, }, - { - id: "terminal", - label: "Terminal", - icon: Terminal, - shortcut: shortcuts.terminal, - }, ]; // Filter out hidden items @@ -1048,29 +1042,39 @@ export function Sidebar() { if (item.id === "profiles" && hideAiProfiles) { return false; } - if (item.id === "terminal" && hideTerminal) { - return false; - } return true; }); + // Build project items - Terminal is conditionally included + const projectItems: NavItem[] = [ + { + id: "board", + label: "Kanban Board", + icon: LayoutGrid, + shortcut: shortcuts.board, + }, + { + id: "agent", + label: "Agent Runner", + icon: Bot, + shortcut: shortcuts.agent, + }, + ]; + + // Add Terminal to Project section if not hidden + if (!hideTerminal) { + projectItems.push({ + id: "terminal", + label: "Terminal", + icon: Terminal, + shortcut: shortcuts.terminal, + }); + } + return [ { label: "Project", - items: [ - { - id: "board", - label: "Kanban Board", - icon: LayoutGrid, - shortcut: shortcuts.board, - }, - { - id: "agent", - label: "Agent Runner", - icon: Bot, - shortcut: shortcuts.agent, - }, - ], + items: projectItems, }, { label: "Tools", diff --git a/apps/ui/src/components/ui/category-autocomplete.tsx b/apps/ui/src/components/ui/category-autocomplete.tsx index 236a08c0..956e6a96 100644 --- a/apps/ui/src/components/ui/category-autocomplete.tsx +++ b/apps/ui/src/components/ui/category-autocomplete.tsx @@ -1,5 +1,5 @@ -import * as React from "react"; +import { Tag } from "lucide-react"; import { Autocomplete } from "@/components/ui/autocomplete"; interface CategoryAutocompleteProps { @@ -9,6 +9,7 @@ interface CategoryAutocompleteProps { placeholder?: string; className?: string; disabled?: boolean; + error?: boolean; "data-testid"?: string; } @@ -19,6 +20,7 @@ export function CategoryAutocomplete({ placeholder = "Select or type a category...", className, disabled = false, + error = false, "data-testid": testId, }: CategoryAutocompleteProps) { return ( @@ -27,10 +29,14 @@ export function CategoryAutocomplete({ onChange={onChange} options={suggestions} placeholder={placeholder} - searchPlaceholder="Search category..." + searchPlaceholder="Search or type new category..." emptyMessage="No category found." className={className} disabled={disabled} + error={error} + icon={Tag} + allowCreate + createLabel={(v) => `Create "${v}"`} data-testid={testId} itemTestIdPrefix="category-option" /> diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 066b264b..0c9449b2 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -495,6 +495,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); @@ -915,6 +956,7 @@ export function BoardView() { { @@ -955,6 +997,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 && ( - - + )} + + {/* Worktrees section - only show if enabled */} + {useWorktreesEnabled && ( + <> +
+ + Worktrees: + +
+ {nonMainWorktrees.map((worktree) => { + const cardCount = branchCardCounts?.[worktree.branch]; + return ( + + ); + })} + + + + +
+ + )}
); } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 4da00182..83ba64f3 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -203,6 +203,9 @@ export interface FeaturesAPI { projectPath: string, featureId: string ) => Promise<{ success: boolean; content?: string | null; error?: string }>; + generateTitle: ( + description: string + ) => Promise<{ success: boolean; title?: string; error?: string }>; } export interface AutoModeAPI { @@ -2606,6 +2609,14 @@ function createMockFeaturesAPI(): FeaturesAPI { const content = mockFileSystem[agentOutputPath]; return { success: true, content: content || null }; }, + + generateTitle: async (description: string) => { + console.log("[Mock] Generating title for:", description.substring(0, 50)); + // Mock title generation - just take first few words + const words = description.split(/\s+/).slice(0, 6).join(" "); + const title = words.length > 40 ? words.substring(0, 40) + "..." : words; + return { success: true, title: `Add ${title}` }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index d881bf9f..5814fa08 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -512,6 +512,8 @@ export class HttpApiClient implements ElectronAPI { this.post("/api/features/delete", { projectPath, featureId }), getAgentOutput: (projectPath: string, featureId: string) => this.post("/api/features/agent-output", { projectPath, featureId }), + generateTitle: (description: string) => + this.post("/api/features/generate-title", { description }), }; // Auto Mode API diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 31997252..ee128598 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -280,6 +280,8 @@ export interface AIProfile { export interface Feature { id: string; + title?: string; + titleGenerating?: boolean; category: string; description: string; steps: string[]; diff --git a/apps/ui/tests/utils/components/autocomplete.ts b/apps/ui/tests/utils/components/autocomplete.ts index 4850cf24..f9015b6d 100644 --- a/apps/ui/tests/utils/components/autocomplete.ts +++ b/apps/ui/tests/utils/components/autocomplete.ts @@ -57,3 +57,22 @@ export async function getCategoryOption( .replace(/\s+/g, "-")}`; return page.locator(`[data-testid="${optionTestId}"]`); } + +/** + * Click the "Create new" option for a category that doesn't exist + */ +export async function clickCreateNewCategoryOption( + page: Page +): Promise { + const option = page.locator('[data-testid="category-option-create-new"]'); + await option.click(); +} + +/** + * Get the "Create new" option element for categories + */ +export async function getCreateNewCategoryOption( + page: Page +): Promise { + return page.locator('[data-testid="category-option-create-new"]'); +}