Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Button } from "@vellum/design-library/components/button";
import { Dropdown } from "@vellum/design-library/components/dropdown";
import { Modal } from "@vellum/design-library/components/modal";
import { Typography } from "@vellum/design-library/components/typography";

import type { ProfileWithName } from "@/domains/settings/ai/ai-types";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface BlockedDeleteState {
name: string;
label: string;
isActive: boolean;
callSiteIds: string[];
}

// ---------------------------------------------------------------------------
// BlockedDeleteModal
// ---------------------------------------------------------------------------

export function BlockedDeleteModal({
blocked,
availableReplacements,
replacement,
onReplacementChange,
error,
saving,
onClose,
onConfirm,
}: {
blocked: BlockedDeleteState | null;
availableReplacements: ProfileWithName[];
replacement: string;
onReplacementChange: (value: string) => void;
error: string | null;
saving: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
let summary = "";
if (blocked) {
const display = blocked.label || blocked.name;
if (blocked.isActive && blocked.callSiteIds.length > 0) {
summary = `"${display}" is the active profile and is used by ${blocked.callSiteIds.length} call site(s). Pick a replacement profile.`;
} else if (blocked.isActive) {
summary = `"${display}" is the active profile. Pick a different active profile before deleting, or select a replacement below.`;
} else {
summary = `"${display}" is used by ${blocked.callSiteIds.length} call site(s). Select a replacement profile to reassign them before deleting.`;
}
}

return (
<Modal.Root
open={blocked !== null}
onOpenChange={(next) => {
if (!next) onClose();
}}
>
<Modal.Content size="sm">
<Modal.Header>
<Modal.Title>Can&apos;t Delete Profile</Modal.Title>
</Modal.Header>
<Modal.Body className="space-y-4">
<Typography variant="body-medium-default" as="p">
{summary}
</Typography>
{blocked && blocked.callSiteIds.length > 0 && (
<ul className="space-y-1 pl-1">
{blocked.callSiteIds.map((id) => (
<li
key={id}
className="text-body-small-default text-(--content-secondary)"
>
• <code>{id}</code>
</li>
))}
</ul>
)}
<div className="space-y-1">
<label className="block text-body-small-default text-[var(--content-tertiary)]">
Replacement profile
</label>
<Dropdown
aria-label="Replacement profile"
value={replacement}
onChange={onReplacementChange}
options={[
{ value: "", label: "Select a replacement…" },
...availableReplacements.map((p) => ({
value: p.name,
label: p.label ?? p.name,
})),
]}
/>
</div>
{error && (
<Typography
variant="body-small-default"
as="p"
className="text-(--system-negative-strong)"
>
{error}
</Typography>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="ghost" size="compact" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="compact"
disabled={!replacement || saving}
onClick={onConfirm}
>
{saving ? "Saving…" : "Reassign and Delete"}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
);
}
180 changes: 180 additions & 0 deletions apps/web/src/domains/settings/ai/manage-profiles-list-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { GripVertical, Trash2 } from "lucide-react";

import { Button } from "@vellum/design-library/components/button";
import { Tag } from "@vellum/design-library/components/tag";
import { Toggle } from "@vellum/design-library/components/toggle";
import { Typography } from "@vellum/design-library/components/typography";

import type { ProfileWithName } from "@/domains/settings/ai/ai-types";
import { AUTO_PROFILE_NAME } from "@/domains/settings/ai/profile-pickers";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

interface DropTarget {
name: string;
after: boolean;
}

interface ProfileListItemProps {
profile: ProfileWithName;
isDragging: boolean;
dropTarget: DropTarget | null;
isDeleting: boolean;
deleteError: string | undefined;
isToggling: boolean;
onDragStart: (e: React.DragEvent) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
onEditClick: () => void;
onDeleteClick: () => void;
onStatusToggle: (active: boolean) => void;
}

// ---------------------------------------------------------------------------
// ProfileListItem
// ---------------------------------------------------------------------------

export function ProfileListItem({
profile,
isDragging,
dropTarget,
isDeleting,
deleteError,
isToggling,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
onEditClick,
onDeleteClick,
onStatusToggle,
}: ProfileListItemProps) {
const isManaged = profile.source === "managed";
const isActive = profile.status !== "disabled";
const isAutoProfile = profile.name === AUTO_PROFILE_NAME;

return (
<div className="relative">
{dropTarget?.name === profile.name && !dropTarget.after && (
<div className="mx-0 h-0.5 rounded-full bg-[var(--border-active)]" />
)}
<div
className={`flex items-center gap-2 rounded-lg pr-2 py-2${isDragging ? " opacity-50" : ""}`}
draggable={!isManaged}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{/* Grip icon — invisible for managed profiles to preserve alignment */}
<GripVertical
className={`h-4 w-4 shrink-0 ${isManaged ? "invisible" : "cursor-grab text-[var(--content-tertiary)]"}`}
/>

{/* Label — dimmed when disabled */}
<div
className={`min-w-0 flex-1${isActive ? "" : " opacity-55"}`}
>
<div className="flex items-center gap-2">
<Typography
variant="body-medium-default"
as="span"
className="text-(--content-default)"
>
{profile.label ?? profile.name}
</Typography>
{isManaged && profile.name !== AUTO_PROFILE_NAME && (
<Tag
tone="positive"
title="Managed by Platform — auth is locked, but you can rename or disable this profile."
>
Platform
</Tag>
)}
</div>
{profile.description ? (
<Typography
variant="body-medium-lighter"
as="p"
className="mt-0.5 text-(--content-tertiary)"
>
{profile.description}
</Typography>
) : null}
{(profile.model ?? profile.provider) ? (
<Typography
variant="body-medium-lighter"
as="p"
className="mt-0.5 text-(--content-tertiary)"
>
{profile.model ?? profile.provider}
</Typography>
) : null}
</div>

{/* Actions */}
<div className="flex shrink-0 items-center gap-2">
<div
className="flex shrink-0 items-center"
title={
isActive
? "Active — toggle to hide from pickers"
: "Disabled — toggle to show in pickers"
}
>
<Toggle
checked={isActive}
onChange={(next) => onStatusToggle(next)}
disabled={isToggling}
aria-label={`${isActive ? "Disable" : "Enable"} ${profile.label ?? profile.name}`}
/>
</div>
<div
className={`flex w-[92px] items-center justify-end gap-2${isAutoProfile ? " invisible" : ""}`}
>
<Button
variant="ghost"
size="compact"
onClick={onEditClick}
>
{isManaged ? "View" : "Edit"}
</Button>
<Button
variant="ghost"
size="compact"
iconOnly={<Trash2 />}
aria-label={`Delete ${profile.label ?? profile.name}`}
disabled={isManaged || isDeleting}
title={
isManaged ? "Managed profiles cannot be deleted" : undefined
}
onClick={onDeleteClick}
tintColor="var(--system-negative-strong)"
/>
</div>
</div>
</div>
{dropTarget?.name === profile.name && dropTarget.after && (
<div className="mx-0 h-0.5 rounded-full bg-[var(--border-active)]" />
)}
{deleteError ? (
<Typography
variant="body-small-default"
as="p"
className="px-2 pb-1 text-(--system-negative-strong)"
>
{deleteError}
</Typography>
) : null}
{profile.name === AUTO_PROFILE_NAME && (
<div className="mx-2 mt-1 border-b border-[var(--border-subtle)]" />
)}
</div>
);
}
Loading