mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
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
This commit is contained in:
@@ -10,6 +10,7 @@ import { createCreateHandler } from "./routes/create.js";
|
|||||||
import { createUpdateHandler } from "./routes/update.js";
|
import { createUpdateHandler } from "./routes/update.js";
|
||||||
import { createDeleteHandler } from "./routes/delete.js";
|
import { createDeleteHandler } from "./routes/delete.js";
|
||||||
import { createAgentOutputHandler } from "./routes/agent-output.js";
|
import { createAgentOutputHandler } from "./routes/agent-output.js";
|
||||||
|
import { createGenerateTitleHandler } from "./routes/generate-title.js";
|
||||||
|
|
||||||
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -20,6 +21,7 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
|||||||
router.post("/update", createUpdateHandler(featureLoader));
|
router.post("/update", createUpdateHandler(featureLoader));
|
||||||
router.post("/delete", createDeleteHandler(featureLoader));
|
router.post("/delete", createDeleteHandler(featureLoader));
|
||||||
router.post("/agent-output", createAgentOutputHandler(featureLoader));
|
router.post("/agent-output", createAgentOutputHandler(featureLoader));
|
||||||
|
router.post("/generate-title", createGenerateTitleHandler());
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
137
apps/server/src/routes/features/routes/generate-title.ts
Normal file
137
apps/server/src/routes/features/routes/generate-title.ts
Normal file
@@ -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<string> {
|
||||||
|
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<void> {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
|
|
||||||
export interface Feature {
|
export interface Feature {
|
||||||
id: string;
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
titleGenerating?: boolean;
|
||||||
category: string;
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
steps?: string[];
|
steps?: string[];
|
||||||
|
|||||||
@@ -466,6 +466,47 @@ export function BoardView() {
|
|||||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
[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
|
// 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
|
// Use a ref to track the latest auto mode state so async operations always check the current value
|
||||||
const autoModeRunningRef = useRef(autoMode.isRunning);
|
const autoModeRunningRef = useRef(autoMode.isRunning);
|
||||||
@@ -886,6 +927,7 @@ export function BoardView() {
|
|||||||
<BoardHeader
|
<BoardHeader
|
||||||
projectName={currentProject.name}
|
projectName={currentProject.name}
|
||||||
maxConcurrency={maxConcurrency}
|
maxConcurrency={maxConcurrency}
|
||||||
|
runningAgentsCount={runningAutoTasks.length}
|
||||||
onConcurrencyChange={setMaxConcurrency}
|
onConcurrencyChange={setMaxConcurrency}
|
||||||
isAutoModeRunning={autoMode.isRunning}
|
isAutoModeRunning={autoMode.isRunning}
|
||||||
onAutoModeToggle={(enabled) => {
|
onAutoModeToggle={(enabled) => {
|
||||||
@@ -926,6 +968,7 @@ export function BoardView() {
|
|||||||
setShowCreateBranchDialog(true);
|
setShowCreateBranchDialog(true);
|
||||||
}}
|
}}
|
||||||
onAddressPRComments={handleAddressPRComments}
|
onAddressPRComments={handleAddressPRComments}
|
||||||
|
onResolveConflicts={handleResolveConflicts}
|
||||||
onRemovedWorktrees={handleRemovedWorktrees}
|
onRemovedWorktrees={handleRemovedWorktrees}
|
||||||
runningFeatureIds={runningAutoTasks}
|
runningFeatureIds={runningAutoTasks}
|
||||||
branchCardCounts={branchCardCounts}
|
branchCardCounts={branchCardCounts}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { HotkeyButton } from "@/components/ui/hotkey-button";
|
|||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
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";
|
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
|
||||||
interface BoardHeaderProps {
|
interface BoardHeaderProps {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
maxConcurrency: number;
|
maxConcurrency: number;
|
||||||
|
runningAgentsCount: number;
|
||||||
onConcurrencyChange: (value: number) => void;
|
onConcurrencyChange: (value: number) => void;
|
||||||
isAutoModeRunning: boolean;
|
isAutoModeRunning: boolean;
|
||||||
onAutoModeToggle: (enabled: boolean) => void;
|
onAutoModeToggle: (enabled: boolean) => void;
|
||||||
@@ -21,6 +22,7 @@ interface BoardHeaderProps {
|
|||||||
export function BoardHeader({
|
export function BoardHeader({
|
||||||
projectName,
|
projectName,
|
||||||
maxConcurrency,
|
maxConcurrency,
|
||||||
|
runningAgentsCount,
|
||||||
onConcurrencyChange,
|
onConcurrencyChange,
|
||||||
isAutoModeRunning,
|
isAutoModeRunning,
|
||||||
onAutoModeToggle,
|
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"
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
|
||||||
data-testid="concurrency-slider-container"
|
data-testid="concurrency-slider-container"
|
||||||
>
|
>
|
||||||
<Users className="w-4 h-4 text-muted-foreground" />
|
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Agents</span>
|
||||||
<Slider
|
<Slider
|
||||||
value={[maxConcurrency]}
|
value={[maxConcurrency]}
|
||||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||||
@@ -52,10 +55,10 @@ export function BoardHeader({
|
|||||||
data-testid="concurrency-slider"
|
data-testid="concurrency-slider"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-sm text-muted-foreground min-w-[2ch] text-center"
|
className="text-sm text-muted-foreground min-w-[5ch] text-center"
|
||||||
data-testid="concurrency-value"
|
data-testid="concurrency-value"
|
||||||
>
|
>
|
||||||
{maxConcurrency}
|
{runningAgentsCount} / {maxConcurrency}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -647,14 +647,24 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<CardTitle
|
{feature.titleGenerating ? (
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground italic">Generating title...</span>
|
||||||
|
</div>
|
||||||
|
) : feature.title ? (
|
||||||
|
<CardTitle className="text-sm font-semibold text-foreground mb-1 line-clamp-2">
|
||||||
|
{feature.title}
|
||||||
|
</CardTitle>
|
||||||
|
) : null}
|
||||||
|
<CardDescription
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm leading-snug break-words hyphens-auto overflow-hidden font-medium text-foreground/90",
|
"text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground",
|
||||||
!isDescriptionExpanded && "line-clamp-3"
|
!isDescriptionExpanded && "line-clamp-3"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{feature.description || feature.summary || feature.id}
|
{feature.description || feature.summary || feature.id}
|
||||||
</CardTitle>
|
</CardDescription>
|
||||||
{(feature.description || feature.summary || "").length > 100 && (
|
{(feature.description || feature.summary || "").length > 100 && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +59,7 @@ interface AddFeatureDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onAdd: (feature: {
|
onAdd: (feature: {
|
||||||
|
title: string;
|
||||||
category: string;
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
steps: string[];
|
steps: string[];
|
||||||
@@ -99,6 +101,7 @@ export function AddFeatureDialog({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
||||||
const [newFeature, setNewFeature] = useState({
|
const [newFeature, setNewFeature] = useState({
|
||||||
|
title: "",
|
||||||
category: "",
|
category: "",
|
||||||
description: "",
|
description: "",
|
||||||
steps: [""],
|
steps: [""],
|
||||||
@@ -186,6 +189,7 @@ export function AddFeatureDialog({
|
|||||||
: newFeature.branchName || "";
|
: newFeature.branchName || "";
|
||||||
|
|
||||||
onAdd({
|
onAdd({
|
||||||
|
title: newFeature.title,
|
||||||
category,
|
category,
|
||||||
description: newFeature.description,
|
description: newFeature.description,
|
||||||
steps: newFeature.steps.filter((s) => s.trim()),
|
steps: newFeature.steps.filter((s) => s.trim()),
|
||||||
@@ -202,6 +206,7 @@ export function AddFeatureDialog({
|
|||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setNewFeature({
|
setNewFeature({
|
||||||
|
title: "",
|
||||||
category: "",
|
category: "",
|
||||||
description: "",
|
description: "",
|
||||||
steps: [""],
|
steps: [""],
|
||||||
@@ -350,6 +355,17 @@ export function AddFeatureDialog({
|
|||||||
error={descriptionError}
|
error={descriptionError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={newFeature.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewFeature({ ...newFeature, title: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Leave blank to auto-generate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||||
import {
|
import {
|
||||||
@@ -61,6 +62,7 @@ interface EditFeatureDialogProps {
|
|||||||
onUpdate: (
|
onUpdate: (
|
||||||
featureId: string,
|
featureId: string,
|
||||||
updates: {
|
updates: {
|
||||||
|
title: string;
|
||||||
category: string;
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
steps: string[];
|
steps: string[];
|
||||||
@@ -159,6 +161,7 @@ export function EditFeatureDialog({
|
|||||||
: editingFeature.branchName || "";
|
: editingFeature.branchName || "";
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
|
title: editingFeature.title ?? "",
|
||||||
category: editingFeature.category,
|
category: editingFeature.category,
|
||||||
description: editingFeature.description,
|
description: editingFeature.description,
|
||||||
steps: editingFeature.steps,
|
steps: editingFeature.steps,
|
||||||
@@ -311,6 +314,21 @@ export function EditFeatureDialog({
|
|||||||
data-testid="edit-feature-description"
|
data-testid="edit-feature-description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-title">Title (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-title"
|
||||||
|
value={editingFeature.title ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
title: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Leave blank to auto-generate"
|
||||||
|
data-testid="edit-feature-title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export function useBoardActions({
|
|||||||
|
|
||||||
const handleAddFeature = useCallback(
|
const handleAddFeature = useCallback(
|
||||||
async (featureData: {
|
async (featureData: {
|
||||||
|
title: string;
|
||||||
category: string;
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
steps: string[];
|
steps: string[];
|
||||||
@@ -141,8 +142,13 @@ export function useBoardActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we need to generate a title
|
||||||
|
const needsTitleGeneration = !featureData.title.trim() && featureData.description.trim();
|
||||||
|
|
||||||
const newFeatureData = {
|
const newFeatureData = {
|
||||||
...featureData,
|
...featureData,
|
||||||
|
title: featureData.title,
|
||||||
|
titleGenerating: needsTitleGeneration,
|
||||||
status: "backlog" as const,
|
status: "backlog" as const,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
};
|
};
|
||||||
@@ -150,14 +156,42 @@ export function useBoardActions({
|
|||||||
// Must await to ensure feature exists on server before user can drag it
|
// Must await to ensure feature exists on server before user can drag it
|
||||||
await persistFeatureCreate(createdFeature);
|
await persistFeatureCreate(createdFeature);
|
||||||
saveCategory(featureData.category);
|
saveCategory(featureData.category);
|
||||||
|
|
||||||
|
// Generate title in the background if needed (non-blocking)
|
||||||
|
if (needsTitleGeneration) {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.features?.generateTitle) {
|
||||||
|
api.features.generateTitle(featureData.description)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.success && result.title) {
|
||||||
|
const titleUpdates = { title: result.title, titleGenerating: false };
|
||||||
|
updateFeature(createdFeature.id, titleUpdates);
|
||||||
|
persistFeatureUpdate(createdFeature.id, titleUpdates);
|
||||||
|
} else {
|
||||||
|
// Clear generating flag even if failed
|
||||||
|
const titleUpdates = { titleGenerating: false };
|
||||||
|
updateFeature(createdFeature.id, titleUpdates);
|
||||||
|
persistFeatureUpdate(createdFeature.id, titleUpdates);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Board] Error generating title:", error);
|
||||||
|
// Clear generating flag on error
|
||||||
|
const titleUpdates = { titleGenerating: false };
|
||||||
|
updateFeature(createdFeature.id, titleUpdates);
|
||||||
|
persistFeatureUpdate(createdFeature.id, titleUpdates);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, currentProject, onWorktreeCreated]
|
[addFeature, persistFeatureCreate, persistFeatureUpdate, updateFeature, saveCategory, useWorktrees, currentProject, onWorktreeCreated]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateFeature = useCallback(
|
const handleUpdateFeature = useCallback(
|
||||||
async (
|
async (
|
||||||
featureId: string,
|
featureId: string,
|
||||||
updates: {
|
updates: {
|
||||||
|
title: string;
|
||||||
category: string;
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
steps: string[];
|
steps: string[];
|
||||||
@@ -212,6 +246,7 @@ export function useBoardActions({
|
|||||||
|
|
||||||
const finalUpdates = {
|
const finalUpdates = {
|
||||||
...updates,
|
...updates,
|
||||||
|
title: updates.title,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Square,
|
Square,
|
||||||
Globe,
|
Globe,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
GitMerge,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { WorktreeInfo, DevServerInfo, PRInfo } from "../types";
|
import type { WorktreeInfo, DevServerInfo, PRInfo } from "../types";
|
||||||
@@ -42,6 +43,7 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
@@ -66,6 +68,7 @@ export function WorktreeActionsDropdown({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onResolveConflicts,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
@@ -160,6 +163,15 @@ export function WorktreeActionsDropdown({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{!worktree.isMain && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onResolveConflicts(worktree)}
|
||||||
|
className="text-xs text-purple-500 focus:text-purple-600"
|
||||||
|
>
|
||||||
|
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Pull & Resolve Conflicts
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onOpenInEditor(worktree)}
|
onClick={() => onOpenInEditor(worktree)}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ interface WorktreeTabProps {
|
|||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
@@ -84,6 +85,7 @@ export function WorktreeTab({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onResolveConflicts,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
@@ -343,6 +345,7 @@ export function WorktreeTab({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onResolveConflicts={onResolveConflicts}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={onStartDevServer}
|
onStartDevServer={onStartDevServer}
|
||||||
onStopDevServer={onStopDevServer}
|
onStopDevServer={onStopDevServer}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export interface WorktreePanelProps {
|
|||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||||
runningFeatureIds?: string[];
|
runningFeatureIds?: string[];
|
||||||
features?: FeatureInfo[];
|
features?: FeatureInfo[];
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function WorktreePanel({
|
|||||||
onCreatePR,
|
onCreatePR,
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onResolveConflicts,
|
||||||
onRemovedWorktrees,
|
onRemovedWorktrees,
|
||||||
runningFeatureIds = [],
|
runningFeatureIds = [],
|
||||||
features = [],
|
features = [],
|
||||||
@@ -148,6 +149,7 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onResolveConflicts={onResolveConflicts}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ export interface FeaturesAPI {
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string
|
featureId: string
|
||||||
) => Promise<{ success: boolean; content?: string | null; error?: string }>;
|
) => Promise<{ success: boolean; content?: string | null; error?: string }>;
|
||||||
|
generateTitle: (
|
||||||
|
description: string
|
||||||
|
) => Promise<{ success: boolean; title?: string; error?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoModeAPI {
|
export interface AutoModeAPI {
|
||||||
@@ -2606,6 +2609,14 @@ function createMockFeaturesAPI(): FeaturesAPI {
|
|||||||
const content = mockFileSystem[agentOutputPath];
|
const content = mockFileSystem[agentOutputPath];
|
||||||
return { success: true, content: content || null };
|
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}` };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -512,6 +512,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post("/api/features/delete", { projectPath, featureId }),
|
this.post("/api/features/delete", { projectPath, featureId }),
|
||||||
getAgentOutput: (projectPath: string, featureId: string) =>
|
getAgentOutput: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/features/agent-output", { projectPath, featureId }),
|
this.post("/api/features/agent-output", { projectPath, featureId }),
|
||||||
|
generateTitle: (description: string) =>
|
||||||
|
this.post("/api/features/generate-title", { description }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto Mode API
|
// Auto Mode API
|
||||||
|
|||||||
@@ -280,6 +280,8 @@ export interface AIProfile {
|
|||||||
|
|
||||||
export interface Feature {
|
export interface Feature {
|
||||||
id: string;
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
titleGenerating?: boolean;
|
||||||
category: string;
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
steps: string[];
|
steps: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user