mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
Merge pull request #99 from AutoMaker-Org/feat/ai-profiles-view-enhancement
Enhanced AI profiles view with better UX and comprehensive test coverage.
This commit is contained in:
@@ -15,7 +15,7 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
// Inner shadow for depth
|
// Inner shadow for depth
|
||||||
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
|
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
|
||||||
// Animated focus ring
|
// Animated focus ring
|
||||||
@@ -39,7 +39,7 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center h-9 w-full rounded-md border border-input bg-input shadow-xs",
|
"flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs",
|
||||||
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
|
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
|
||||||
"transition-[box-shadow,border-color] duration-200 ease-out",
|
"transition-[box-shadow,border-color] duration-200 ease-out",
|
||||||
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none",
|
"placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-border min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none",
|
||||||
// Inner shadow for depth
|
// Inner shadow for depth
|
||||||
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
|
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
|
||||||
// Animated focus ring
|
// Animated focus ring
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
useAppStore,
|
useAppStore,
|
||||||
AIProfile,
|
AIProfile,
|
||||||
AgentModel,
|
|
||||||
ThinkingLevel,
|
|
||||||
ModelProvider,
|
|
||||||
} from "@/store/app-store";
|
} from "@/store/app-store";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { cn, modelSupportsThinking } from "@/lib/utils";
|
|
||||||
import {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
useKeyboardShortcutsConfig,
|
useKeyboardShortcutsConfig,
|
||||||
@@ -23,27 +14,12 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import { Sparkles } from "lucide-react";
|
||||||
UserCircle,
|
|
||||||
Plus,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Brain,
|
|
||||||
Zap,
|
|
||||||
Scale,
|
|
||||||
Cpu,
|
|
||||||
Rocket,
|
|
||||||
Sparkles,
|
|
||||||
GripVertical,
|
|
||||||
Lock,
|
|
||||||
Check,
|
|
||||||
RefreshCw,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
@@ -54,353 +30,13 @@ import {
|
|||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import {
|
||||||
|
SortableProfileCard,
|
||||||
// Icon mapping for profiles
|
ProfileForm,
|
||||||
const PROFILE_ICONS: Record<
|
ProfilesHeader,
|
||||||
string,
|
} from "./profiles-view/components";
|
||||||
React.ComponentType<{ className?: string }>
|
|
||||||
> = {
|
|
||||||
Brain,
|
|
||||||
Zap,
|
|
||||||
Scale,
|
|
||||||
Cpu,
|
|
||||||
Rocket,
|
|
||||||
Sparkles,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Available icons for selection
|
|
||||||
const ICON_OPTIONS = [
|
|
||||||
{ name: "Brain", icon: Brain },
|
|
||||||
{ name: "Zap", icon: Zap },
|
|
||||||
{ name: "Scale", icon: Scale },
|
|
||||||
{ name: "Cpu", icon: Cpu },
|
|
||||||
{ name: "Rocket", icon: Rocket },
|
|
||||||
{ name: "Sparkles", icon: Sparkles },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Model options for the form
|
|
||||||
const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
|
|
||||||
{ id: "haiku", label: "Claude Haiku" },
|
|
||||||
{ id: "sonnet", label: "Claude Sonnet" },
|
|
||||||
{ id: "opus", label: "Claude Opus" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
|
|
||||||
{ id: "none", label: "None" },
|
|
||||||
{ id: "low", label: "Low" },
|
|
||||||
{ id: "medium", label: "Medium" },
|
|
||||||
{ id: "high", label: "High" },
|
|
||||||
{ id: "ultrathink", label: "Ultrathink" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Helper to determine provider from model
|
|
||||||
function getProviderFromModel(model: AgentModel): ModelProvider {
|
|
||||||
return "claude";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sortable Profile Card Component
|
|
||||||
function SortableProfileCard({
|
|
||||||
profile,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
profile: AIProfile;
|
|
||||||
onEdit: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: profile.id });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={cn(
|
|
||||||
"group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all",
|
|
||||||
isDragging && "shadow-lg",
|
|
||||||
profile.isBuiltIn
|
|
||||||
? "border-border/50"
|
|
||||||
: "border-border hover:border-primary/50 hover:shadow-sm"
|
|
||||||
)}
|
|
||||||
data-testid={`profile-card-${profile.id}`}
|
|
||||||
>
|
|
||||||
{/* Drag Handle */}
|
|
||||||
<button
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
|
|
||||||
data-testid={`profile-drag-handle-${profile.id}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Icon */}
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-primary/10"
|
|
||||||
>
|
|
||||||
{IconComponent && (
|
|
||||||
<IconComponent className="w-5 h-5 text-primary" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-foreground">{profile.name}</h3>
|
|
||||||
{profile.isBuiltIn && (
|
|
||||||
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
|
|
||||||
<Lock className="w-2.5 h-2.5" />
|
|
||||||
Built-in
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
|
|
||||||
{profile.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
|
||||||
<span
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10"
|
|
||||||
>
|
|
||||||
{profile.model}
|
|
||||||
</span>
|
|
||||||
{profile.thinkingLevel !== "none" && (
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
|
|
||||||
{profile.thinkingLevel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{!profile.isBuiltIn && (
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onEdit}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
data-testid={`edit-profile-${profile.id}`}
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onDelete}
|
|
||||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
|
||||||
data-testid={`delete-profile-${profile.id}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Profile Form Component
|
|
||||||
function ProfileForm({
|
|
||||||
profile,
|
|
||||||
onSave,
|
|
||||||
onCancel,
|
|
||||||
isEditing,
|
|
||||||
hotkeyActive,
|
|
||||||
}: {
|
|
||||||
profile: Partial<AIProfile>;
|
|
||||||
onSave: (profile: Omit<AIProfile, "id">) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
isEditing: boolean;
|
|
||||||
hotkeyActive: boolean;
|
|
||||||
}) {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: profile.name || "",
|
|
||||||
description: profile.description || "",
|
|
||||||
model: profile.model || ("opus" as AgentModel),
|
|
||||||
thinkingLevel: profile.thinkingLevel || ("none" as ThinkingLevel),
|
|
||||||
icon: profile.icon || "Brain",
|
|
||||||
});
|
|
||||||
|
|
||||||
const provider = getProviderFromModel(formData.model);
|
|
||||||
const supportsThinking = modelSupportsThinking(formData.model);
|
|
||||||
|
|
||||||
const handleModelChange = (model: AgentModel) => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
model,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!formData.name.trim()) {
|
|
||||||
toast.error("Please enter a profile name");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSave({
|
|
||||||
name: formData.name.trim(),
|
|
||||||
description: formData.description.trim(),
|
|
||||||
model: formData.model,
|
|
||||||
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
|
|
||||||
provider,
|
|
||||||
isBuiltIn: false,
|
|
||||||
icon: formData.icon,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="profile-name">Profile Name</Label>
|
|
||||||
<Input
|
|
||||||
id="profile-name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="e.g., Heavy Task, Quick Fix"
|
|
||||||
data-testid="profile-name-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="profile-description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="profile-description"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, description: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="Describe when to use this profile..."
|
|
||||||
rows={2}
|
|
||||||
data-testid="profile-description-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Icon Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Icon</Label>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData({ ...formData, icon: name })}
|
|
||||||
className={cn(
|
|
||||||
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
|
|
||||||
formData.icon === name
|
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
|
||||||
: "bg-background hover:bg-accent border-input"
|
|
||||||
)}
|
|
||||||
data-testid={`icon-select-${name}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<Brain className="w-4 h-4 text-primary" />
|
|
||||||
Model
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{CLAUDE_MODELS.map(({ id, label }) => (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleModelChange(id)}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
|
||||||
formData.model === id
|
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
|
||||||
: "bg-background hover:bg-accent border-input"
|
|
||||||
)}
|
|
||||||
data-testid={`model-select-${id}`}
|
|
||||||
>
|
|
||||||
{label.replace("Claude ", "")}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thinking Level */}
|
|
||||||
{supportsThinking && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<Brain className="w-4 h-4 text-amber-500" />
|
|
||||||
Thinking Level
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{THINKING_LEVELS.map(({ id, label }) => (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setFormData({ ...formData, thinkingLevel: id });
|
|
||||||
if (id === "ultrathink") {
|
|
||||||
toast.warning("Ultrathink uses extensive reasoning", {
|
|
||||||
description:
|
|
||||||
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
|
|
||||||
duration: 4000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
|
||||||
formData.thinkingLevel === id
|
|
||||||
? "bg-amber-500 text-white border-amber-400"
|
|
||||||
: "bg-background hover:bg-accent border-input"
|
|
||||||
)}
|
|
||||||
data-testid={`thinking-select-${id}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Higher levels give more time to reason through complex problems.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<DialogFooter className="pt-4">
|
|
||||||
<Button variant="ghost" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<HotkeyButton
|
|
||||||
onClick={handleSubmit}
|
|
||||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
|
||||||
hotkeyActive={hotkeyActive}
|
|
||||||
data-testid="save-profile-button"
|
|
||||||
>
|
|
||||||
{isEditing ? "Save Changes" : "Create Profile"}
|
|
||||||
</HotkeyButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfilesView() {
|
export function ProfilesView() {
|
||||||
const {
|
const {
|
||||||
@@ -415,6 +51,7 @@ export function ProfilesView() {
|
|||||||
|
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
|
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
|
||||||
|
const [profileToDelete, setProfileToDelete] = useState<AIProfile | null>(null);
|
||||||
|
|
||||||
// Sensors for drag-and-drop
|
// Sensors for drag-and-drop
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -469,13 +106,14 @@ export function ProfilesView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteProfile = (profile: AIProfile) => {
|
const confirmDeleteProfile = () => {
|
||||||
if (profile.isBuiltIn) return;
|
if (!profileToDelete) return;
|
||||||
|
|
||||||
removeAIProfile(profile.id);
|
removeAIProfile(profileToDelete.id);
|
||||||
toast.success("Profile deleted", {
|
toast.success("Profile deleted", {
|
||||||
description: `Deleted "${profile.name}" profile`,
|
description: `Deleted "${profileToDelete.name}" profile`,
|
||||||
});
|
});
|
||||||
|
setProfileToDelete(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetProfiles = () => {
|
const handleResetProfiles = () => {
|
||||||
@@ -508,45 +146,11 @@ export function ProfilesView() {
|
|||||||
data-testid="profiles-view"
|
data-testid="profiles-view"
|
||||||
>
|
>
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
<ProfilesHeader
|
||||||
<div className="px-8 py-6">
|
onResetProfiles={handleResetProfiles}
|
||||||
<div className="flex items-center justify-between">
|
onAddProfile={() => setShowAddDialog(true)}
|
||||||
<div className="flex items-center gap-3">
|
addProfileHotkey={shortcuts.addProfile}
|
||||||
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
|
/>
|
||||||
<UserCircle className="w-5 h-5 text-primary-foreground" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
|
||||||
AI Profiles
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Create and manage model configuration presets
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleResetProfiles}
|
|
||||||
data-testid="refresh-profiles-button"
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
Refresh Defaults
|
|
||||||
</Button>
|
|
||||||
<HotkeyButton
|
|
||||||
onClick={() => setShowAddDialog(true)}
|
|
||||||
hotkey={shortcuts.addProfile}
|
|
||||||
hotkeyActive={false}
|
|
||||||
data-testid="add-profile-button"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
New Profile
|
|
||||||
</HotkeyButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-8">
|
<div className="flex-1 overflow-y-auto p-8">
|
||||||
@@ -562,19 +166,14 @@ export function ProfilesView() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{customProfiles.length === 0 ? (
|
{customProfiles.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-border p-8 text-center">
|
<div
|
||||||
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50" />
|
className="group rounded-xl border border-dashed border-border p-8 text-center transition-all duration-300 hover:border-primary hover:bg-primary/5 cursor-pointer"
|
||||||
<p className="text-muted-foreground">
|
onClick={() => setShowAddDialog(true)}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50 transition-all duration-300 group-hover:text-primary group-hover:opacity-100 group-hover:scale-110 group-hover:rotate-12" />
|
||||||
|
<p className="text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
|
||||||
No custom profiles yet. Create one to get started!
|
No custom profiles yet. Create one to get started!
|
||||||
</p>
|
</p>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="mt-4"
|
|
||||||
onClick={() => setShowAddDialog(true)}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Create Profile
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -592,7 +191,7 @@ export function ProfilesView() {
|
|||||||
key={profile.id}
|
key={profile.id}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
onEdit={() => setEditingProfile(profile)}
|
onEdit={() => setEditingProfile(profile)}
|
||||||
onDelete={() => handleDeleteProfile(profile)}
|
onDelete={() => setProfileToDelete(profile)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -642,8 +241,8 @@ export function ProfilesView() {
|
|||||||
|
|
||||||
{/* Add Profile Dialog */}
|
{/* Add Profile Dialog */}
|
||||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||||
<DialogContent data-testid="add-profile-dialog">
|
<DialogContent data-testid="add-profile-dialog" className="flex flex-col max-h-[calc(100vh-4rem)]">
|
||||||
<DialogHeader>
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle>Create New Profile</DialogTitle>
|
<DialogTitle>Create New Profile</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Define a reusable model configuration preset.
|
Define a reusable model configuration preset.
|
||||||
@@ -664,8 +263,8 @@ export function ProfilesView() {
|
|||||||
open={!!editingProfile}
|
open={!!editingProfile}
|
||||||
onOpenChange={() => setEditingProfile(null)}
|
onOpenChange={() => setEditingProfile(null)}
|
||||||
>
|
>
|
||||||
<DialogContent data-testid="edit-profile-dialog">
|
<DialogContent data-testid="edit-profile-dialog" className="flex flex-col max-h-[calc(100vh-4rem)]">
|
||||||
<DialogHeader>
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle>Edit Profile</DialogTitle>
|
<DialogTitle>Edit Profile</DialogTitle>
|
||||||
<DialogDescription>Modify your profile settings.</DialogDescription>
|
<DialogDescription>Modify your profile settings.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -680,6 +279,22 @@ export function ProfilesView() {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!profileToDelete}
|
||||||
|
onOpenChange={(open) => !open && setProfileToDelete(null)}
|
||||||
|
onConfirm={confirmDeleteProfile}
|
||||||
|
title="Delete Profile"
|
||||||
|
description={
|
||||||
|
profileToDelete
|
||||||
|
? `Are you sure you want to delete "${profileToDelete.name}"? This action cannot be undone.`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
confirmText="Delete Profile"
|
||||||
|
testId="delete-profile-confirm-dialog"
|
||||||
|
confirmTestId="confirm-delete-profile-button"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { SortableProfileCard } from "./sortable-profile-card";
|
||||||
|
export { ProfileForm } from "./profile-form";
|
||||||
|
export { ProfilesHeader } from "./profiles-header";
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn, modelSupportsThinking } from "@/lib/utils";
|
||||||
|
import { DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Brain } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { AIProfile, AgentModel, ThinkingLevel } from "@/store/app-store";
|
||||||
|
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from "../constants";
|
||||||
|
import { getProviderFromModel } from "../utils";
|
||||||
|
|
||||||
|
interface ProfileFormProps {
|
||||||
|
profile: Partial<AIProfile>;
|
||||||
|
onSave: (profile: Omit<AIProfile, "id">) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isEditing: boolean;
|
||||||
|
hotkeyActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileForm({
|
||||||
|
profile,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
isEditing,
|
||||||
|
hotkeyActive,
|
||||||
|
}: ProfileFormProps) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: profile.name || "",
|
||||||
|
description: profile.description || "",
|
||||||
|
model: profile.model || ("opus" as AgentModel),
|
||||||
|
thinkingLevel: profile.thinkingLevel || ("none" as ThinkingLevel),
|
||||||
|
icon: profile.icon || "Brain",
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = getProviderFromModel(formData.model);
|
||||||
|
const supportsThinking = modelSupportsThinking(formData.model);
|
||||||
|
|
||||||
|
const handleModelChange = (model: AgentModel) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
toast.error("Please enter a profile name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave({
|
||||||
|
name: formData.name.trim(),
|
||||||
|
description: formData.description.trim(),
|
||||||
|
model: formData.model,
|
||||||
|
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
|
||||||
|
provider,
|
||||||
|
isBuiltIn: false,
|
||||||
|
icon: formData.icon,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="overflow-y-auto flex-1 min-h-0 space-y-4 pr-3 -mr-3 pl-1">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<Label htmlFor="profile-name">Profile Name</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="e.g., Heavy Task, Quick Fix"
|
||||||
|
data-testid="profile-name-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profile-description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="profile-description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Describe when to use this profile..."
|
||||||
|
rows={2}
|
||||||
|
data-testid="profile-description-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Icon</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, icon: name })}
|
||||||
|
className={cn(
|
||||||
|
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
|
||||||
|
formData.icon === name
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-background hover:bg-accent border-border"
|
||||||
|
)}
|
||||||
|
data-testid={`icon-select-${name}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4 text-primary" />
|
||||||
|
Model
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{CLAUDE_MODELS.map(({ id, label }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleModelChange(id)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||||
|
formData.model === id
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-background hover:bg-accent border-border"
|
||||||
|
)}
|
||||||
|
data-testid={`model-select-${id}`}
|
||||||
|
>
|
||||||
|
{label.replace("Claude ", "")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thinking Level */}
|
||||||
|
{supportsThinking && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4 text-amber-500" />
|
||||||
|
Thinking Level
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{THINKING_LEVELS.map(({ id, label }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFormData({ ...formData, thinkingLevel: id });
|
||||||
|
if (id === "ultrathink") {
|
||||||
|
toast.warning("Ultrathink uses extensive reasoning", {
|
||||||
|
description:
|
||||||
|
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||||
|
formData.thinkingLevel === id
|
||||||
|
? "bg-amber-500 text-white border-amber-400"
|
||||||
|
: "bg-background hover:bg-accent border-border"
|
||||||
|
)}
|
||||||
|
data-testid={`thinking-select-${id}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Higher levels give more time to reason through complex problems.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<DialogFooter className="pt-4 border-t border-border mt-4 shrink-0">
|
||||||
|
<Button variant="ghost" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<HotkeyButton
|
||||||
|
onClick={handleSubmit}
|
||||||
|
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||||
|
hotkeyActive={hotkeyActive}
|
||||||
|
data-testid="save-profile-button"
|
||||||
|
>
|
||||||
|
{isEditing ? "Save Changes" : "Create Profile"}
|
||||||
|
</HotkeyButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import { UserCircle, Plus, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
interface ProfilesHeaderProps {
|
||||||
|
onResetProfiles: () => void;
|
||||||
|
onAddProfile: () => void;
|
||||||
|
addProfileHotkey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfilesHeader({
|
||||||
|
onResetProfiles,
|
||||||
|
onAddProfile,
|
||||||
|
addProfileHotkey,
|
||||||
|
}: ProfilesHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
||||||
|
<div className="px-8 py-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
|
||||||
|
<UserCircle className="w-5 h-5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">
|
||||||
|
AI Profiles
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Create and manage model configuration presets
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onResetProfiles}
|
||||||
|
data-testid="refresh-profiles-button"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Refresh Defaults
|
||||||
|
</Button>
|
||||||
|
<HotkeyButton
|
||||||
|
onClick={onAddProfile}
|
||||||
|
hotkey={addProfileHotkey}
|
||||||
|
hotkeyActive={false}
|
||||||
|
data-testid="add-profile-button"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Profile
|
||||||
|
</HotkeyButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { GripVertical, Lock, Pencil, Trash2, Brain } from "lucide-react";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import type { AIProfile } from "@/store/app-store";
|
||||||
|
import { PROFILE_ICONS } from "../constants";
|
||||||
|
|
||||||
|
interface SortableProfileCardProps {
|
||||||
|
profile: AIProfile;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableProfileCard({
|
||||||
|
profile,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: SortableProfileCardProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: profile.id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all",
|
||||||
|
isDragging && "shadow-lg",
|
||||||
|
profile.isBuiltIn
|
||||||
|
? "border-border/50"
|
||||||
|
: "border-border hover:border-primary/50 hover:shadow-sm"
|
||||||
|
)}
|
||||||
|
data-testid={`profile-card-${profile.id}`}
|
||||||
|
>
|
||||||
|
{/* Drag Handle */}
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
|
||||||
|
data-testid={`profile-drag-handle-${profile.id}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Reorder ${profile.name} profile`}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-primary/10"
|
||||||
|
>
|
||||||
|
{IconComponent && (
|
||||||
|
<IconComponent className="w-5 h-5 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-foreground">{profile.name}</h3>
|
||||||
|
{profile.isBuiltIn && (
|
||||||
|
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||||
|
<Lock className="w-2.5 h-2.5" />
|
||||||
|
Built-in
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
|
||||||
|
{profile.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10"
|
||||||
|
>
|
||||||
|
{profile.model}
|
||||||
|
</span>
|
||||||
|
{profile.thinkingLevel !== "none" && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
|
||||||
|
{profile.thinkingLevel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{!profile.isBuiltIn && (
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
data-testid={`edit-profile-${profile.id}`}
|
||||||
|
aria-label={`Edit ${profile.name} profile`}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||||
|
data-testid={`delete-profile-${profile.id}`}
|
||||||
|
aria-label={`Delete ${profile.name} profile`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
48
apps/app/src/components/views/profiles-view/constants.ts
Normal file
48
apps/app/src/components/views/profiles-view/constants.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
Zap,
|
||||||
|
Scale,
|
||||||
|
Cpu,
|
||||||
|
Rocket,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { AgentModel, ThinkingLevel } from "@/store/app-store";
|
||||||
|
|
||||||
|
// Icon mapping for profiles
|
||||||
|
export const PROFILE_ICONS: Record<
|
||||||
|
string,
|
||||||
|
React.ComponentType<{ className?: string }>
|
||||||
|
> = {
|
||||||
|
Brain,
|
||||||
|
Zap,
|
||||||
|
Scale,
|
||||||
|
Cpu,
|
||||||
|
Rocket,
|
||||||
|
Sparkles,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Available icons for selection
|
||||||
|
export const ICON_OPTIONS = [
|
||||||
|
{ name: "Brain", icon: Brain },
|
||||||
|
{ name: "Zap", icon: Zap },
|
||||||
|
{ name: "Scale", icon: Scale },
|
||||||
|
{ name: "Cpu", icon: Cpu },
|
||||||
|
{ name: "Rocket", icon: Rocket },
|
||||||
|
{ name: "Sparkles", icon: Sparkles },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Model options for the form
|
||||||
|
export const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
|
||||||
|
{ id: "haiku", label: "Claude Haiku" },
|
||||||
|
{ id: "sonnet", label: "Claude Sonnet" },
|
||||||
|
{ id: "opus", label: "Claude Opus" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
|
||||||
|
{ id: "none", label: "None" },
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High" },
|
||||||
|
{ id: "ultrathink", label: "Ultrathink" },
|
||||||
|
];
|
||||||
|
|
||||||
7
apps/app/src/components/views/profiles-view/utils.ts
Normal file
7
apps/app/src/components/views/profiles-view/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { AgentModel, ModelProvider } from "@/store/app-store";
|
||||||
|
|
||||||
|
// Helper to determine provider from model
|
||||||
|
export function getProviderFromModel(model: AgentModel): ModelProvider {
|
||||||
|
return "claude";
|
||||||
|
}
|
||||||
|
|
||||||
1043
apps/app/tests/profiles-view.spec.ts
Normal file
1043
apps/app/tests/profiles-view.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,17 +25,29 @@ export async function waitForErrorToast(
|
|||||||
titleText?: string,
|
titleText?: string,
|
||||||
options?: { timeout?: number }
|
options?: { timeout?: number }
|
||||||
): Promise<Locator> {
|
): Promise<Locator> {
|
||||||
// Sonner toasts use data-sonner-toast and data-type="error" for error toasts
|
// Try multiple selectors for error toasts since Sonner versions may differ
|
||||||
const toastSelector = titleText
|
// 1. Try with data-type="error" attribute
|
||||||
? `[data-sonner-toast][data-type="error"]:has-text("${titleText}")`
|
// 2. Fallback to any toast with the text (error styling might vary)
|
||||||
: '[data-sonner-toast][data-type="error"]';
|
const timeout = options?.timeout ?? 5000;
|
||||||
|
|
||||||
const toast = page.locator(toastSelector).first();
|
if (titleText) {
|
||||||
await toast.waitFor({
|
// First try specific error type, then fallback to any toast with text
|
||||||
timeout: options?.timeout ?? 5000,
|
const errorToast = page.locator(
|
||||||
state: "visible",
|
`[data-sonner-toast][data-type="error"]:has-text("${titleText}"), [data-sonner-toast]:has-text("${titleText}")`
|
||||||
});
|
).first();
|
||||||
return toast;
|
await errorToast.waitFor({
|
||||||
|
timeout,
|
||||||
|
state: "visible",
|
||||||
|
});
|
||||||
|
return errorToast;
|
||||||
|
} else {
|
||||||
|
const errorToast = page.locator('[data-sonner-toast][data-type="error"]').first();
|
||||||
|
await errorToast.waitFor({
|
||||||
|
timeout,
|
||||||
|
state: "visible",
|
||||||
|
});
|
||||||
|
return errorToast;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import { Page } from "@playwright/test";
|
import { Page } from "@playwright/test";
|
||||||
import { getByTestId, getButtonByText } from "./elements";
|
import { getByTestId, getButtonByText } from "./elements";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the platform-specific modifier key (Meta for Mac, Control for Windows/Linux)
|
||||||
|
* This is used for keyboard shortcuts like Cmd+Enter or Ctrl+Enter
|
||||||
|
*/
|
||||||
|
export function getPlatformModifier(): "Meta" | "Control" {
|
||||||
|
return process.platform === "darwin" ? "Meta" : "Control";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Press the platform-specific modifier + a key (e.g., Cmd+Enter or Ctrl+Enter)
|
||||||
|
*/
|
||||||
|
export async function pressModifierEnter(page: Page): Promise<void> {
|
||||||
|
const modifier = getPlatformModifier();
|
||||||
|
await page.keyboard.press(`${modifier}+Enter`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Click an element by its data-testid attribute
|
* Click an element by its data-testid attribute
|
||||||
*/
|
*/
|
||||||
@@ -56,8 +72,15 @@ export async function focusOnInput(page: Page, testId: string): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Close any open dialog by pressing Escape
|
* Close any open dialog by pressing Escape
|
||||||
|
* Waits for dialog to be removed from DOM rather than using arbitrary timeout
|
||||||
*/
|
*/
|
||||||
export async function closeDialogWithEscape(page: Page): Promise<void> {
|
export async function closeDialogWithEscape(page: Page): Promise<void> {
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await page.waitForTimeout(100); // Give dialog time to close
|
// Wait for any dialog overlay to disappear
|
||||||
|
await page
|
||||||
|
.locator('[data-radix-dialog-overlay], [role="dialog"]')
|
||||||
|
.waitFor({ state: "hidden", timeout: 5000 })
|
||||||
|
.catch(() => {
|
||||||
|
// Dialog may have already closed or not exist
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export * from "./views/spec-editor";
|
|||||||
export * from "./views/agent";
|
export * from "./views/agent";
|
||||||
export * from "./views/settings";
|
export * from "./views/settings";
|
||||||
export * from "./views/setup";
|
export * from "./views/setup";
|
||||||
|
export * from "./views/profiles";
|
||||||
|
|
||||||
// Component utilities
|
// Component utilities
|
||||||
export * from "./components/dialogs";
|
export * from "./components/dialogs";
|
||||||
|
|||||||
@@ -633,3 +633,120 @@ export async function setupComplete(page: Page): Promise<void> {
|
|||||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a mock project with AI profiles for testing the profiles view
|
||||||
|
* Includes default built-in profiles and optionally custom profiles
|
||||||
|
*/
|
||||||
|
export async function setupMockProjectWithProfiles(
|
||||||
|
page: Page,
|
||||||
|
options?: {
|
||||||
|
customProfilesCount?: number;
|
||||||
|
includeBuiltIn?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
await page.addInitScript((opts: typeof options) => {
|
||||||
|
const mockProject = {
|
||||||
|
id: "test-project-1",
|
||||||
|
name: "Test Project",
|
||||||
|
path: "/mock/test-project",
|
||||||
|
lastOpened: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default built-in profiles (same as DEFAULT_AI_PROFILES from app-store.ts)
|
||||||
|
const builtInProfiles = [
|
||||||
|
{
|
||||||
|
id: "profile-heavy-task",
|
||||||
|
name: "Heavy Task",
|
||||||
|
description:
|
||||||
|
"Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.",
|
||||||
|
model: "opus" as const,
|
||||||
|
thinkingLevel: "ultrathink" as const,
|
||||||
|
provider: "claude" as const,
|
||||||
|
isBuiltIn: true,
|
||||||
|
icon: "Brain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "profile-balanced",
|
||||||
|
name: "Balanced",
|
||||||
|
description:
|
||||||
|
"Claude Sonnet with medium thinking for typical development tasks.",
|
||||||
|
model: "sonnet" as const,
|
||||||
|
thinkingLevel: "medium" as const,
|
||||||
|
provider: "claude" as const,
|
||||||
|
isBuiltIn: true,
|
||||||
|
icon: "Scale",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "profile-quick-edit",
|
||||||
|
name: "Quick Edit",
|
||||||
|
description: "Claude Haiku for fast, simple edits and minor fixes.",
|
||||||
|
model: "haiku" as const,
|
||||||
|
thinkingLevel: "none" as const,
|
||||||
|
provider: "claude" as const,
|
||||||
|
isBuiltIn: true,
|
||||||
|
icon: "Zap",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate custom profiles if requested
|
||||||
|
const customProfiles = [];
|
||||||
|
const customCount = opts?.customProfilesCount ?? 0;
|
||||||
|
for (let i = 0; i < customCount; i++) {
|
||||||
|
customProfiles.push({
|
||||||
|
id: `custom-profile-${i + 1}`,
|
||||||
|
name: `Custom Profile ${i + 1}`,
|
||||||
|
description: `Test custom profile ${i + 1}`,
|
||||||
|
model: ["haiku", "sonnet", "opus"][i % 3] as
|
||||||
|
| "haiku"
|
||||||
|
| "sonnet"
|
||||||
|
| "opus",
|
||||||
|
thinkingLevel: ["none", "low", "medium", "high"][i % 4] as
|
||||||
|
| "none"
|
||||||
|
| "low"
|
||||||
|
| "medium"
|
||||||
|
| "high",
|
||||||
|
provider: "claude" as const,
|
||||||
|
isBuiltIn: false,
|
||||||
|
icon: ["Brain", "Zap", "Scale", "Cpu", "Rocket", "Sparkles"][i % 6],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine profiles (built-in first, then custom)
|
||||||
|
const includeBuiltIn = opts?.includeBuiltIn !== false; // Default to true
|
||||||
|
const aiProfiles = includeBuiltIn
|
||||||
|
? [...builtInProfiles, ...customProfiles]
|
||||||
|
: customProfiles;
|
||||||
|
|
||||||
|
const mockState = {
|
||||||
|
state: {
|
||||||
|
projects: [mockProject],
|
||||||
|
currentProject: mockProject,
|
||||||
|
theme: "dark",
|
||||||
|
sidebarOpen: true,
|
||||||
|
apiKeys: { anthropic: "", google: "", openai: "" },
|
||||||
|
chatSessions: [],
|
||||||
|
chatHistoryOpen: false,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
aiProfiles: aiProfiles,
|
||||||
|
features: [],
|
||||||
|
currentView: "board", // Start at board, will navigate to profiles
|
||||||
|
},
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||||
|
|
||||||
|
// Also mark setup as complete to skip the setup wizard
|
||||||
|
const setupState = {
|
||||||
|
state: {
|
||||||
|
isFirstRun: false,
|
||||||
|
setupComplete: true,
|
||||||
|
currentStep: "complete",
|
||||||
|
skipClaudeSetup: false,
|
||||||
|
},
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||||
|
}, options);
|
||||||
|
}
|
||||||
|
|||||||
572
apps/app/tests/utils/views/profiles.ts
Normal file
572
apps/app/tests/utils/views/profiles.ts
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
import { Page, Locator } from "@playwright/test";
|
||||||
|
import { clickElement, fillInput } from "../core/interactions";
|
||||||
|
import { waitForElement, waitForElementHidden } from "../core/waiting";
|
||||||
|
import { getByTestId } from "../core/elements";
|
||||||
|
import { navigateToView } from "../navigation/views";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the profiles view
|
||||||
|
*/
|
||||||
|
export async function navigateToProfiles(page: Page): Promise<void> {
|
||||||
|
// Click the profiles navigation button
|
||||||
|
await navigateToView(page, "profiles");
|
||||||
|
|
||||||
|
// Wait for profiles view to be visible
|
||||||
|
await page.waitForSelector('[data-testid="profiles-view"]', {
|
||||||
|
state: "visible",
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profile List Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific profile card by ID
|
||||||
|
*/
|
||||||
|
export async function getProfileCard(
|
||||||
|
page: Page,
|
||||||
|
profileId: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return getByTestId(page, `profile-card-${profileId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all profile cards (both built-in and custom)
|
||||||
|
*/
|
||||||
|
export async function getProfileCards(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid^="profile-card-"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get only custom profile cards
|
||||||
|
*/
|
||||||
|
export async function getCustomProfiles(page: Page): Promise<Locator> {
|
||||||
|
// Custom profiles don't have the "Built-in" badge
|
||||||
|
return page.locator('[data-testid^="profile-card-"]').filter({
|
||||||
|
hasNot: page.locator('text="Built-in"'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get only built-in profile cards
|
||||||
|
*/
|
||||||
|
export async function getBuiltInProfiles(page: Page): Promise<Locator> {
|
||||||
|
// Built-in profiles have the lock icon and "Built-in" text
|
||||||
|
return page.locator('[data-testid^="profile-card-"]:has-text("Built-in")');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the number of custom profiles
|
||||||
|
*/
|
||||||
|
export async function countCustomProfiles(page: Page): Promise<number> {
|
||||||
|
const customProfiles = await getCustomProfiles(page);
|
||||||
|
return customProfiles.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the number of built-in profiles
|
||||||
|
*/
|
||||||
|
export async function countBuiltInProfiles(page: Page): Promise<number> {
|
||||||
|
const builtInProfiles = await getBuiltInProfiles(page);
|
||||||
|
return await builtInProfiles.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all custom profile IDs
|
||||||
|
*/
|
||||||
|
export async function getCustomProfileIds(page: Page): Promise<string[]> {
|
||||||
|
const allCards = await page.locator('[data-testid^="profile-card-"]').all();
|
||||||
|
const customIds: string[] = [];
|
||||||
|
|
||||||
|
for (const card of allCards) {
|
||||||
|
const builtInText = card.locator('text="Built-in"');
|
||||||
|
const isBuiltIn = (await builtInText.count()) > 0;
|
||||||
|
if (!isBuiltIn) {
|
||||||
|
const testId = await card.getAttribute("data-testid");
|
||||||
|
if (testId) {
|
||||||
|
// Extract ID from "profile-card-{id}"
|
||||||
|
const profileId = testId.replace("profile-card-", "");
|
||||||
|
customIds.push(profileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return customIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the first custom profile ID (useful after creating a profile)
|
||||||
|
*/
|
||||||
|
export async function getFirstCustomProfileId(page: Page): Promise<string | null> {
|
||||||
|
const ids = await getCustomProfileIds(page);
|
||||||
|
return ids.length > 0 ? ids[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CRUD Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the "New Profile" button in the header
|
||||||
|
*/
|
||||||
|
export async function clickNewProfileButton(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "add-profile-button");
|
||||||
|
await waitForElement(page, "add-profile-dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the empty state card to create a new profile
|
||||||
|
*/
|
||||||
|
export async function clickEmptyState(page: Page): Promise<void> {
|
||||||
|
const emptyState = page.locator(
|
||||||
|
'.group.rounded-xl.border.border-dashed[class*="cursor-pointer"]'
|
||||||
|
);
|
||||||
|
await emptyState.click();
|
||||||
|
await waitForElement(page, "add-profile-dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the profile form with data
|
||||||
|
*/
|
||||||
|
export async function fillProfileForm(
|
||||||
|
page: Page,
|
||||||
|
data: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
model?: string;
|
||||||
|
thinkingLevel?: string;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
if (data.name !== undefined) {
|
||||||
|
await fillProfileName(page, data.name);
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
await fillProfileDescription(page, data.description);
|
||||||
|
}
|
||||||
|
if (data.icon !== undefined) {
|
||||||
|
await selectIcon(page, data.icon);
|
||||||
|
}
|
||||||
|
if (data.model !== undefined) {
|
||||||
|
await selectModel(page, data.model);
|
||||||
|
}
|
||||||
|
if (data.thinkingLevel !== undefined) {
|
||||||
|
await selectThinkingLevel(page, data.thinkingLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the save button to create/update a profile
|
||||||
|
*/
|
||||||
|
export async function saveProfile(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "save-profile-button");
|
||||||
|
// Wait for dialog to close
|
||||||
|
await waitForElementHidden(page, "add-profile-dialog").catch(() => {});
|
||||||
|
await waitForElementHidden(page, "edit-profile-dialog").catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the cancel button in the profile dialog
|
||||||
|
*/
|
||||||
|
export async function cancelProfileDialog(page: Page): Promise<void> {
|
||||||
|
// Look for cancel button in dialog footer
|
||||||
|
const cancelButton = page.locator('button:has-text("Cancel")');
|
||||||
|
await cancelButton.click();
|
||||||
|
// Wait for dialog to close
|
||||||
|
await waitForElementHidden(page, "add-profile-dialog").catch(() => {});
|
||||||
|
await waitForElementHidden(page, "edit-profile-dialog").catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the edit button for a specific profile
|
||||||
|
*/
|
||||||
|
export async function clickEditProfile(
|
||||||
|
page: Page,
|
||||||
|
profileId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await clickElement(page, `edit-profile-${profileId}`);
|
||||||
|
await waitForElement(page, "edit-profile-dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the delete button for a specific profile
|
||||||
|
*/
|
||||||
|
export async function clickDeleteProfile(
|
||||||
|
page: Page,
|
||||||
|
profileId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await clickElement(page, `delete-profile-${profileId}`);
|
||||||
|
await waitForElement(page, "delete-profile-confirm-dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm profile deletion in the dialog
|
||||||
|
*/
|
||||||
|
export async function confirmDeleteProfile(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "confirm-delete-profile-button");
|
||||||
|
await waitForElementHidden(page, "delete-profile-confirm-dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel profile deletion
|
||||||
|
*/
|
||||||
|
export async function cancelDeleteProfile(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "cancel-delete-button");
|
||||||
|
await waitForElementHidden(page, "delete-profile-confirm-dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Form Field Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the profile name field
|
||||||
|
*/
|
||||||
|
export async function fillProfileName(
|
||||||
|
page: Page,
|
||||||
|
name: string
|
||||||
|
): Promise<void> {
|
||||||
|
await fillInput(page, "profile-name-input", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the profile description field
|
||||||
|
*/
|
||||||
|
export async function fillProfileDescription(
|
||||||
|
page: Page,
|
||||||
|
description: string
|
||||||
|
): Promise<void> {
|
||||||
|
await fillInput(page, "profile-description-input", description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select an icon for the profile
|
||||||
|
* @param iconName - Name of the icon: Brain, Zap, Scale, Cpu, Rocket, Sparkles
|
||||||
|
*/
|
||||||
|
export async function selectIcon(page: Page, iconName: string): Promise<void> {
|
||||||
|
await clickElement(page, `icon-select-${iconName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a model for the profile
|
||||||
|
* @param modelId - Model ID: haiku, sonnet, opus
|
||||||
|
*/
|
||||||
|
export async function selectModel(page: Page, modelId: string): Promise<void> {
|
||||||
|
await clickElement(page, `model-select-${modelId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a thinking level for the profile
|
||||||
|
* @param level - Thinking level: none, low, medium, high, ultrathink
|
||||||
|
*/
|
||||||
|
export async function selectThinkingLevel(
|
||||||
|
page: Page,
|
||||||
|
level: string
|
||||||
|
): Promise<void> {
|
||||||
|
await clickElement(page, `thinking-select-${level}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently selected icon
|
||||||
|
*/
|
||||||
|
export async function getSelectedIcon(page: Page): Promise<string | null> {
|
||||||
|
// Find the icon button with primary background
|
||||||
|
const selectedIcon = page.locator(
|
||||||
|
'[data-testid^="icon-select-"][class*="bg-primary"]'
|
||||||
|
);
|
||||||
|
const testId = await selectedIcon.getAttribute("data-testid");
|
||||||
|
return testId ? testId.replace("icon-select-", "") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently selected model
|
||||||
|
*/
|
||||||
|
export async function getSelectedModel(page: Page): Promise<string | null> {
|
||||||
|
// Find the model button with primary background
|
||||||
|
const selectedModel = page.locator(
|
||||||
|
'[data-testid^="model-select-"][class*="bg-primary"]'
|
||||||
|
);
|
||||||
|
const testId = await selectedModel.getAttribute("data-testid");
|
||||||
|
return testId ? testId.replace("model-select-", "") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently selected thinking level
|
||||||
|
*/
|
||||||
|
export async function getSelectedThinkingLevel(
|
||||||
|
page: Page
|
||||||
|
): Promise<string | null> {
|
||||||
|
// Find the thinking level button with amber background
|
||||||
|
const selectedLevel = page.locator(
|
||||||
|
'[data-testid^="thinking-select-"][class*="bg-amber-500"]'
|
||||||
|
);
|
||||||
|
const testId = await selectedLevel.getAttribute("data-testid");
|
||||||
|
return testId ? testId.replace("thinking-select-", "") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Dialog Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the add profile dialog is open
|
||||||
|
*/
|
||||||
|
export async function isAddProfileDialogOpen(page: Page): Promise<boolean> {
|
||||||
|
const dialog = await getByTestId(page, "add-profile-dialog");
|
||||||
|
return await dialog.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the edit profile dialog is open
|
||||||
|
*/
|
||||||
|
export async function isEditProfileDialogOpen(page: Page): Promise<boolean> {
|
||||||
|
const dialog = await getByTestId(page, "edit-profile-dialog");
|
||||||
|
return await dialog.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the delete confirmation dialog is open
|
||||||
|
*/
|
||||||
|
export async function isDeleteConfirmDialogOpen(page: Page): Promise<boolean> {
|
||||||
|
const dialog = await getByTestId(page, "delete-profile-confirm-dialog");
|
||||||
|
return await dialog.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for any profile dialog to close
|
||||||
|
* This ensures all dialog animations complete before proceeding
|
||||||
|
*/
|
||||||
|
export async function waitForDialogClose(page: Page): Promise<void> {
|
||||||
|
// Wait for all profile dialogs to be hidden
|
||||||
|
await Promise.all([
|
||||||
|
waitForElementHidden(page, "add-profile-dialog").catch(() => {}),
|
||||||
|
waitForElementHidden(page, "edit-profile-dialog").catch(() => {}),
|
||||||
|
waitForElementHidden(page, "delete-profile-confirm-dialog").catch(
|
||||||
|
() => {}
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Also wait for any Radix dialog overlay to be removed (handles animation)
|
||||||
|
await page
|
||||||
|
.locator('[data-radix-dialog-overlay]')
|
||||||
|
.waitFor({ state: "hidden", timeout: 2000 })
|
||||||
|
.catch(() => {
|
||||||
|
// Overlay may not exist
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profile Card Inspection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the profile name from a card
|
||||||
|
*/
|
||||||
|
export async function getProfileName(
|
||||||
|
page: Page,
|
||||||
|
profileId: string
|
||||||
|
): Promise<string> {
|
||||||
|
const card = await getProfileCard(page, profileId);
|
||||||
|
const nameElement = card.locator("h3");
|
||||||
|
return await nameElement.textContent().then((text) => text?.trim() || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the profile description from a card
|
||||||
|
*/
|
||||||
|
export async function getProfileDescription(
|
||||||
|
page: Page,
|
||||||
|
profileId: string
|
||||||
|
): Promise<string> {
|
||||||
|
const card = await getProfileCard(page, profileId);
|
||||||
|
const descElement = card.locator("p").first();
|
||||||
|
return await descElement.textContent().then((text) => text?.trim() || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the profile model badge text from a card
|
||||||
|
*/
|
||||||
|
export async function getProfileModel(
|
||||||
|
page: Page,
|
||||||
|
profileId: string
|
||||||
|
): Promise<string> {
|
||||||
|
const card = await getProfileCard(page, profileId);
|
||||||
|
const modelBadge = card.locator(
|
||||||
|
'span[class*="border-primary"]:has-text("haiku"), span[class*="border-primary"]:has-text("sonnet"), span[class*="border-primary"]:has-text("opus")'
|
||||||
|
);
|
||||||
|
return await modelBadge.textContent().then((text) => text?.trim() || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the profile thinking level badge text from a card
|
||||||
|
*/
|
||||||
|
export async function getProfileThinkingLevel(
|
||||||
|
page: Page,
|
||||||
|
profileId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const card = await getProfileCard(page, profileId);
|
||||||
|
const thinkingBadge = card.locator('span[class*="border-amber-500"]');
|
||||||
|
const isVisible = await thinkingBadge.isVisible().catch(() => false);
|
||||||
|
if (!isVisible) return null;
|
||||||
|
return await thinkingBadge.textContent().then((text) => text?.trim() || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a profile has the built-in badge
|
||||||
|
*/
|
||||||
|
export async function isBuiltInProfile(
|
||||||
|
page: Page,
|
||||||
|
profileId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const card = await getProfileCard(page, profileId);
|
||||||
|
const builtInBadge = card.locator('span:has-text("Built-in")');
|
||||||
|
return await builtInBadge.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the edit button is visible for a profile
|
||||||
|
*/
|
||||||
|
export async function isEditButtonVisible(
|
||||||
|
page: Page,
|
||||||
|
profileId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const card = await getProfileCard(page, profileId);
|
||||||
|
// Hover over card to make buttons visible
|
||||||
|
await card.hover();
|
||||||
|
const editButton = await getByTestId(page, `edit-profile-${profileId}`);
|
||||||
|
// Wait for button to become visible after hover (handles CSS transition)
|
||||||
|
try {
|
||||||
|
await editButton.waitFor({ state: "visible", timeout: 2000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the delete button is visible for a profile
|
||||||
|
*/
|
||||||
|
export async function isDeleteButtonVisible(
|
||||||
|
page: Page,
|
||||||
|
profileId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const card = await getProfileCard(page, profileId);
|
||||||
|
// Hover over card to make buttons visible
|
||||||
|
await card.hover();
|
||||||
|
const deleteButton = await getByTestId(page, `delete-profile-${profileId}`);
|
||||||
|
// Wait for button to become visible after hover (handles CSS transition)
|
||||||
|
try {
|
||||||
|
await deleteButton.waitFor({ state: "visible", timeout: 2000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Drag & Drop
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag a profile from one position to another
|
||||||
|
* Uses the drag handle and dnd-kit library pattern
|
||||||
|
*
|
||||||
|
* Note: dnd-kit requires pointer events with specific timing for drag recognition.
|
||||||
|
* Manual mouse operations are needed because Playwright's dragTo doesn't work
|
||||||
|
* reliably with dnd-kit's pointer-based drag detection.
|
||||||
|
*
|
||||||
|
* @param fromIndex - 0-based index of the profile to drag
|
||||||
|
* @param toIndex - 0-based index of the target position
|
||||||
|
*/
|
||||||
|
export async function dragProfile(
|
||||||
|
page: Page,
|
||||||
|
fromIndex: number,
|
||||||
|
toIndex: number
|
||||||
|
): Promise<void> {
|
||||||
|
// Get all profile cards
|
||||||
|
const cards = await page.locator('[data-testid^="profile-card-"]').all();
|
||||||
|
|
||||||
|
if (fromIndex >= cards.length || toIndex >= cards.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid drag indices: fromIndex=${fromIndex}, toIndex=${toIndex}, total=${cards.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromCard = cards[fromIndex];
|
||||||
|
const toCard = cards[toIndex];
|
||||||
|
|
||||||
|
// Get the drag handle within the source card
|
||||||
|
const dragHandle = fromCard.locator('[data-testid^="profile-drag-handle-"]');
|
||||||
|
|
||||||
|
// Ensure drag handle is visible and ready
|
||||||
|
await dragHandle.waitFor({ state: "visible", timeout: 5000 });
|
||||||
|
|
||||||
|
// Get bounding boxes
|
||||||
|
const handleBox = await dragHandle.boundingBox();
|
||||||
|
const toBox = await toCard.boundingBox();
|
||||||
|
|
||||||
|
if (!handleBox || !toBox) {
|
||||||
|
throw new Error("Unable to get bounding boxes for drag operation");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start position (center of drag handle)
|
||||||
|
const startX = handleBox.x + handleBox.width / 2;
|
||||||
|
const startY = handleBox.y + handleBox.height / 2;
|
||||||
|
|
||||||
|
// End position (center of target card)
|
||||||
|
const endX = toBox.x + toBox.width / 2;
|
||||||
|
const endY = toBox.y + toBox.height / 2;
|
||||||
|
|
||||||
|
// Perform manual drag operation
|
||||||
|
// dnd-kit needs pointer events in a specific sequence
|
||||||
|
await page.mouse.move(startX, startY);
|
||||||
|
await page.mouse.down();
|
||||||
|
|
||||||
|
// dnd-kit requires a brief hold before recognizing the drag gesture
|
||||||
|
// This is a library requirement, not an arbitrary timeout
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
|
||||||
|
// Move to target in steps for smoother drag recognition
|
||||||
|
await page.mouse.move(endX, endY, { steps: 10 });
|
||||||
|
|
||||||
|
// Brief pause before drop
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
// Wait for reorder animation to complete
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current order of all profile IDs
|
||||||
|
* Returns array of profile IDs in display order
|
||||||
|
*/
|
||||||
|
export async function getProfileOrder(page: Page): Promise<string[]> {
|
||||||
|
const cards = await page.locator('[data-testid^="profile-card-"]').all();
|
||||||
|
const ids: string[] = [];
|
||||||
|
|
||||||
|
for (const card of cards) {
|
||||||
|
const testId = await card.getAttribute("data-testid");
|
||||||
|
if (testId) {
|
||||||
|
// Extract profile ID from data-testid="profile-card-{id}"
|
||||||
|
const profileId = testId.replace("profile-card-", "");
|
||||||
|
ids.push(profileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Header Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the "Refresh Defaults" button
|
||||||
|
*/
|
||||||
|
export async function clickRefreshDefaults(page: Page): Promise<void> {
|
||||||
|
await clickElement(page, "refresh-profiles-button");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user