mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
better labels
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[];
|
||||||
|
|||||||
@@ -1029,12 +1029,6 @@ export function Sidebar() {
|
|||||||
icon: UserCircle,
|
icon: UserCircle,
|
||||||
shortcut: shortcuts.profiles,
|
shortcut: shortcuts.profiles,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "terminal",
|
|
||||||
label: "Terminal",
|
|
||||||
icon: Terminal,
|
|
||||||
shortcut: shortcuts.terminal,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter out hidden items
|
// Filter out hidden items
|
||||||
@@ -1048,29 +1042,39 @@ export function Sidebar() {
|
|||||||
if (item.id === "profiles" && hideAiProfiles) {
|
if (item.id === "profiles" && hideAiProfiles) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (item.id === "terminal" && hideTerminal) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
label: "Project",
|
label: "Project",
|
||||||
items: [
|
items: projectItems,
|
||||||
{
|
|
||||||
id: "board",
|
|
||||||
label: "Kanban Board",
|
|
||||||
icon: LayoutGrid,
|
|
||||||
shortcut: shortcuts.board,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "agent",
|
|
||||||
label: "Agent Runner",
|
|
||||||
icon: Bot,
|
|
||||||
shortcut: shortcuts.agent,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Tools",
|
label: "Tools",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import { Tag } from "lucide-react";
|
||||||
import { Autocomplete } from "@/components/ui/autocomplete";
|
import { Autocomplete } from "@/components/ui/autocomplete";
|
||||||
|
|
||||||
interface CategoryAutocompleteProps {
|
interface CategoryAutocompleteProps {
|
||||||
@@ -9,6 +9,7 @@ interface CategoryAutocompleteProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
error?: boolean;
|
||||||
"data-testid"?: string;
|
"data-testid"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export function CategoryAutocomplete({
|
|||||||
placeholder = "Select or type a category...",
|
placeholder = "Select or type a category...",
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
error = false,
|
||||||
"data-testid": testId,
|
"data-testid": testId,
|
||||||
}: CategoryAutocompleteProps) {
|
}: CategoryAutocompleteProps) {
|
||||||
return (
|
return (
|
||||||
@@ -27,10 +29,14 @@ export function CategoryAutocomplete({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={suggestions}
|
options={suggestions}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
searchPlaceholder="Search category..."
|
searchPlaceholder="Search or type new category..."
|
||||||
emptyMessage="No category found."
|
emptyMessage="No category found."
|
||||||
className={className}
|
className={className}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
error={error}
|
||||||
|
icon={Tag}
|
||||||
|
allowCreate
|
||||||
|
createLabel={(v) => `Create "${v}"`}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
itemTestIdPrefix="category-option"
|
itemTestIdPrefix="category-option"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -495,6 +495,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);
|
||||||
@@ -915,6 +956,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) => {
|
||||||
@@ -955,6 +997,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>
|
||||||
|
|||||||
@@ -89,6 +89,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[];
|
||||||
@@ -148,8 +149,14 @@ 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,
|
||||||
};
|
};
|
||||||
@@ -157,14 +164,56 @@ 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, onWorktreeAutoSelect]
|
[
|
||||||
|
addFeature,
|
||||||
|
persistFeatureCreate,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
updateFeature,
|
||||||
|
saveCategory,
|
||||||
|
useWorktrees,
|
||||||
|
currentProject,
|
||||||
|
onWorktreeCreated,
|
||||||
|
onWorktreeAutoSelect,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
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[];
|
||||||
@@ -219,6 +268,7 @@ export function useBoardActions({
|
|||||||
|
|
||||||
const finalUpdates = {
|
const finalUpdates = {
|
||||||
...updates,
|
...updates,
|
||||||
|
title: updates.title,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,7 +279,15 @@ export function useBoardActions({
|
|||||||
}
|
}
|
||||||
setEditingFeature(null);
|
setEditingFeature(null);
|
||||||
},
|
},
|
||||||
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated]
|
[
|
||||||
|
updateFeature,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
saveCategory,
|
||||||
|
setEditingFeature,
|
||||||
|
useWorktrees,
|
||||||
|
currentProject,
|
||||||
|
onWorktreeCreated,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteFeature = useCallback(
|
const handleDeleteFeature = useCallback(
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -339,6 +341,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[];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function WorktreePanel({
|
|||||||
onCreatePR,
|
onCreatePR,
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onResolveConflicts,
|
||||||
onRemovedWorktrees,
|
onRemovedWorktrees,
|
||||||
runningFeatureIds = [],
|
runningFeatureIds = [],
|
||||||
features = [],
|
features = [],
|
||||||
@@ -102,97 +103,156 @@ export function WorktreePanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!useWorktreesEnabled) {
|
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||||
return null;
|
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||||
|
{/* Branch section - always visible */}
|
||||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
|
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2">
|
||||||
{worktrees.map((worktree) => {
|
{mainWorktree && (
|
||||||
const cardCount = branchCardCounts?.[worktree.branch];
|
<WorktreeTab
|
||||||
return (
|
key={mainWorktree.path}
|
||||||
<WorktreeTab
|
worktree={mainWorktree}
|
||||||
key={worktree.path}
|
cardCount={branchCardCounts?.[mainWorktree.branch]}
|
||||||
worktree={worktree}
|
hasChanges={mainWorktree.hasChanges}
|
||||||
cardCount={cardCount}
|
changedFilesCount={mainWorktree.changedFilesCount}
|
||||||
hasChanges={worktree.hasChanges}
|
isSelected={isWorktreeSelected(mainWorktree)}
|
||||||
changedFilesCount={worktree.changedFilesCount}
|
isRunning={hasRunningFeatures(mainWorktree)}
|
||||||
isSelected={isWorktreeSelected(worktree)}
|
isActivating={isActivating}
|
||||||
isRunning={hasRunningFeatures(worktree)}
|
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||||
isActivating={isActivating}
|
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||||
isDevServerRunning={isDevServerRunning(worktree)}
|
defaultEditorName={defaultEditorName}
|
||||||
devServerInfo={getDevServerInfo(worktree)}
|
branches={branches}
|
||||||
defaultEditorName={defaultEditorName}
|
filteredBranches={filteredBranches}
|
||||||
branches={branches}
|
branchFilter={branchFilter}
|
||||||
filteredBranches={filteredBranches}
|
isLoadingBranches={isLoadingBranches}
|
||||||
branchFilter={branchFilter}
|
isSwitching={isSwitching}
|
||||||
isLoadingBranches={isLoadingBranches}
|
isPulling={isPulling}
|
||||||
isSwitching={isSwitching}
|
isPushing={isPushing}
|
||||||
isPulling={isPulling}
|
isStartingDevServer={isStartingDevServer}
|
||||||
isPushing={isPushing}
|
aheadCount={aheadCount}
|
||||||
isStartingDevServer={isStartingDevServer}
|
behindCount={behindCount}
|
||||||
aheadCount={aheadCount}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
behindCount={behindCount}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
|
||||||
onSelectWorktree={handleSelectWorktree}
|
mainWorktree
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
|
)}
|
||||||
worktree
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
|
||||||
)}
|
mainWorktree
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
|
)}
|
||||||
worktree
|
onBranchFilterChange={setBranchFilter}
|
||||||
)}
|
onSwitchBranch={handleSwitchBranch}
|
||||||
onBranchFilterChange={setBranchFilter}
|
onCreateBranch={onCreateBranch}
|
||||||
onSwitchBranch={handleSwitchBranch}
|
onPull={handlePull}
|
||||||
onCreateBranch={onCreateBranch}
|
onPush={handlePush}
|
||||||
onPull={handlePull}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onPush={handlePush}
|
onCommit={onCommit}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onCreatePR={onCreatePR}
|
||||||
onCommit={onCommit}
|
onAddressPRComments={onAddressPRComments}
|
||||||
onCreatePR={onCreatePR}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={onCreateWorktree}
|
|
||||||
title="Create new worktree"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={async () => {
|
|
||||||
const removedWorktrees = await fetchWorktrees();
|
|
||||||
if (
|
|
||||||
removedWorktrees &&
|
|
||||||
removedWorktrees.length > 0 &&
|
|
||||||
onRemovedWorktrees
|
|
||||||
) {
|
|
||||||
onRemovedWorktrees(removedWorktrees);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
|
||||||
title="Refresh worktrees"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
|
||||||
/>
|
/>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Worktrees section - only show if enabled */}
|
||||||
|
{useWorktreesEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="w-px h-5 bg-border mx-2" />
|
||||||
|
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{nonMainWorktrees.map((worktree) => {
|
||||||
|
const cardCount = branchCardCounts?.[worktree.branch];
|
||||||
|
return (
|
||||||
|
<WorktreeTab
|
||||||
|
key={worktree.path}
|
||||||
|
worktree={worktree}
|
||||||
|
cardCount={cardCount}
|
||||||
|
hasChanges={worktree.hasChanges}
|
||||||
|
changedFilesCount={worktree.changedFilesCount}
|
||||||
|
isSelected={isWorktreeSelected(worktree)}
|
||||||
|
isRunning={hasRunningFeatures(worktree)}
|
||||||
|
isActivating={isActivating}
|
||||||
|
isDevServerRunning={isDevServerRunning(worktree)}
|
||||||
|
devServerInfo={getDevServerInfo(worktree)}
|
||||||
|
defaultEditorName={defaultEditorName}
|
||||||
|
branches={branches}
|
||||||
|
filteredBranches={filteredBranches}
|
||||||
|
branchFilter={branchFilter}
|
||||||
|
isLoadingBranches={isLoadingBranches}
|
||||||
|
isSwitching={isSwitching}
|
||||||
|
isPulling={isPulling}
|
||||||
|
isPushing={isPushing}
|
||||||
|
isStartingDevServer={isStartingDevServer}
|
||||||
|
aheadCount={aheadCount}
|
||||||
|
behindCount={behindCount}
|
||||||
|
onSelectWorktree={handleSelectWorktree}
|
||||||
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
|
||||||
|
worktree
|
||||||
|
)}
|
||||||
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
|
||||||
|
worktree
|
||||||
|
)}
|
||||||
|
onBranchFilterChange={setBranchFilter}
|
||||||
|
onSwitchBranch={handleSwitchBranch}
|
||||||
|
onCreateBranch={onCreateBranch}
|
||||||
|
onPull={handlePull}
|
||||||
|
onPush={handlePush}
|
||||||
|
onOpenInEditor={handleOpenInEditor}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onCreatePR={onCreatePR}
|
||||||
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onResolveConflicts={onResolveConflicts}
|
||||||
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
|
onStartDevServer={handleStartDevServer}
|
||||||
|
onStopDevServer={handleStopDevServer}
|
||||||
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onCreateWorktree}
|
||||||
|
title="Create new worktree"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={async () => {
|
||||||
|
const removedWorktrees = await fetchWorktrees();
|
||||||
|
if (
|
||||||
|
removedWorktrees &&
|
||||||
|
removedWorktrees.length > 0 &&
|
||||||
|
onRemovedWorktrees
|
||||||
|
) {
|
||||||
|
onRemovedWorktrees(removedWorktrees);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
title="Refresh worktrees"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -57,3 +57,22 @@ export async function getCategoryOption(
|
|||||||
.replace(/\s+/g, "-")}`;
|
.replace(/\s+/g, "-")}`;
|
||||||
return page.locator(`[data-testid="${optionTestId}"]`);
|
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<void> {
|
||||||
|
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<Locator> {
|
||||||
|
return page.locator('[data-testid="category-option-create-new"]');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user