From 334b82bfb4261169817b8764373145cac8f2f7d8 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Thu, 18 Dec 2025 23:48:35 -0500 Subject: [PATCH 1/4] Changes from category --- .../components/ui/category-autocomplete.tsx | 10 ++++++++-- .../tests/utils/components/autocomplete.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/ui/category-autocomplete.tsx b/apps/app/src/components/ui/category-autocomplete.tsx index 125a15b7..03781808 100644 --- a/apps/app/src/components/ui/category-autocomplete.tsx +++ b/apps/app/src/components/ui/category-autocomplete.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import { Tag } from "lucide-react"; import { Autocomplete } from "@/components/ui/autocomplete"; interface CategoryAutocompleteProps { @@ -10,6 +10,7 @@ interface CategoryAutocompleteProps { placeholder?: string; className?: string; disabled?: boolean; + error?: boolean; "data-testid"?: string; } @@ -20,6 +21,7 @@ export function CategoryAutocomplete({ placeholder = "Select or type a category...", className, disabled = false, + error = false, "data-testid": testId, }: CategoryAutocompleteProps) { return ( @@ -28,10 +30,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/app/tests/utils/components/autocomplete.ts b/apps/app/tests/utils/components/autocomplete.ts index 4850cf24..f9015b6d 100644 --- a/apps/app/tests/utils/components/autocomplete.ts +++ b/apps/app/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"]'); +} From 37ce09e07c1e8a3de68c4b6fb8fcf10f42b56b71 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Thu, 18 Dec 2025 23:48:47 -0500 Subject: [PATCH 2/4] Changes from terminals-mpve --- apps/app/src/components/layout/sidebar.tsx | 50 ++++++++++++---------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index 6f534db4..e6eff78c 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -1032,12 +1032,6 @@ export function Sidebar() { icon: UserCircle, shortcut: shortcuts.profiles, }, - { - id: "terminal", - label: "Terminal", - icon: Terminal, - shortcut: shortcuts.terminal, - }, ]; // Filter out hidden items @@ -1051,29 +1045,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", From 36b4bd6c5e7a37ef7764b5df2d9c615b34497851 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 19 Dec 2025 21:57:45 -0500 Subject: [PATCH 3/4] Changes from category --- apps/app/next-env.d.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 apps/app/next-env.d.ts diff --git a/apps/app/next-env.d.ts b/apps/app/next-env.d.ts new file mode 100644 index 00000000..c4b7818f --- /dev/null +++ b/apps/app/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From fcb2e904eb09d0599f6e4dce2d3ae2cb4e30eb07 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 19 Dec 2025 23:36:29 -0500 Subject: [PATCH 4/4] feat: add auto-generated titles for features - Add POST /features/generate-title endpoint using Claude Haiku - Generate concise titles (5-10 words) from feature descriptions - Display titles in kanban cards with loading state - Add optional title field to add/edit feature dialogs - Auto-generate titles when description provided but title empty - Add 'Pull & Resolve Conflicts' action to worktree dropdown - Show running agents count in board header (X / Y format) - Update Feature interface to include title and titleGenerating fields --- apps/server/src/routes/features/index.ts | 2 + .../routes/features/routes/generate-title.ts | 137 ++++++++++++++++++ apps/server/src/services/feature-loader.ts | 2 + apps/ui/src/components/views/board-view.tsx | 43 ++++++ .../views/board-view/board-header.tsx | 11 +- .../board-view/components/kanban-card.tsx | 16 +- .../board-view/dialogs/add-feature-dialog.tsx | 16 ++ .../dialogs/edit-feature-dialog.tsx | 18 +++ .../board-view/hooks/use-board-actions.ts | 37 ++++- .../components/worktree-actions-dropdown.tsx | 12 ++ .../components/worktree-tab.tsx | 3 + .../views/board-view/worktree-panel/types.ts | 1 + .../worktree-panel/worktree-panel.tsx | 2 + apps/ui/src/lib/electron.ts | 11 ++ apps/ui/src/lib/http-api-client.ts | 2 + apps/ui/src/store/app-store.ts | 2 + 16 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/routes/features/routes/generate-title.ts 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 && (