feat(sidebar): inline label create, live sync, and kebab submenu parity (#706)

Portal-wrap DropdownMenuSubContent so the kebab Labels submenu renders outside
the clipped dropdown container, matching ContextMenuSubContent and fixing the
empty/broken kebab submenu.

Thread onLabelsChanged from LabelsSection through SettingsModal to
EditorLayout so label creates and deletes in Settings propagate to the
sidebar menus without a page refresh.

Add an inline "New label" form in both the kebab and context menu label
submenus that creates and assigns a label in one interaction, removing the
Settings round-trip from the label assignment flow.
This commit is contained in:
Anso
2026-04-20 09:57:20 -04:00
committed by GitHub
parent 5f2d67848c
commit 75370d8fce
9 changed files with 223 additions and 58 deletions
+35 -3
View File
@@ -20,7 +20,7 @@ import { springs } from '@/lib/motion';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { type Label as StackLabel } from './label-types';
import { type Label as StackLabel, type LabelColor } from './label-types';
import { UserProfileDropdown } from './UserProfileDropdown';
import { NotificationPanel } from './NotificationPanel';
import { apiFetch, fetchForNode } from '@/lib/api';
@@ -597,7 +597,7 @@ export default function EditorLayout() {
setStackStatuses(prev => ({ ...prev, [stackFile]: status }));
};
const refreshLabels = async () => {
const refreshLabels = useCallback(async () => {
if (!isPaid) return;
try {
const [labelsRes, assignmentsRes] = await Promise.all([
@@ -609,7 +609,7 @@ export default function EditorLayout() {
} catch {
// Labels are non-critical; fail silently
}
};
}, [isPaid]);
/**
* Populate the per-stack "pending git source update" map. Runs on mount and
@@ -1902,6 +1902,37 @@ export default function EditorLayout() {
toast.dismiss(loadingId);
}
},
createAndAssignLabel: async (name: string, color: LabelColor) => {
const loadingId = toast.loading('Creating label...');
try {
const createRes = await apiFetch('/labels', {
method: 'POST',
body: JSON.stringify({ name, color }),
});
if (!createRes.ok) {
const data = await createRes.json().catch(() => ({}));
throw new Error((data as { error?: string })?.error || 'Failed to create label.');
}
const created: StackLabel = await createRes.json();
const currentIds = (stackLabelMap[file] ?? []).map(l => l.id);
const newIds = [...currentIds, created.id];
const assignRes = await apiFetch(`/stacks/${encodeURIComponent(file)}/labels`, {
method: 'PUT',
body: JSON.stringify({ labelIds: newIds }),
});
if (!assignRes.ok) {
const data = await assignRes.json().catch(() => ({}));
throw new Error((data as { error?: string })?.error || 'Failed to assign label.');
}
toast.success(`Label "${created.name}" created.`);
refreshLabels();
} catch (err: unknown) {
toast.error((err as Error)?.message || 'Failed to create label.');
throw err;
} finally {
toast.dismiss(loadingId);
}
},
openLabelManager: () => { setSettingsInitialSection('labels'); setSettingsModalOpen(true); },
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -2844,6 +2875,7 @@ export default function EditorLayout() {
isOpen={settingsModalOpen}
onClose={() => { setSettingsModalOpen(false); setSettingsInitialSection('account'); }}
initialSection={settingsInitialSection}
onLabelsChanged={refreshLabels}
/>
{/* Stack Alert Sheet */}
+3 -2
View File
@@ -63,9 +63,10 @@ interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
initialSection?: SectionId;
onLabelsChanged?: () => void;
}
export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModalProps) {
export function SettingsModal({ isOpen, onClose, initialSection, onLabelsChanged }: SettingsModalProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const { license, isPaid } = useLicense();
@@ -302,7 +303,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
case 'sso': return <SSOSection />;
case 'api-tokens': return <ApiTokensSection />;
case 'registries': return <RegistriesSection />;
case 'labels': return <LabelsSection />;
case 'labels': return <LabelsSection onLabelsChanged={onLabelsChanged} />;
case 'system':
return (
<SystemSection
@@ -26,7 +26,11 @@ import { CapabilityGate } from '../CapabilityGate';
import { LabelDot } from '../LabelPill';
import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from '../label-types';
export function LabelsSection() {
interface LabelsSectionProps {
onLabelsChanged?: () => void;
}
export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
const [labels, setLabels] = useState<Label[]>([]);
const [loading, setLoading] = useState(true);
const [assignmentCounts, setAssignmentCounts] = useState<Record<number, number>>({});
@@ -98,6 +102,7 @@ export function LabelsSection() {
toast.success(`Label ${editingLabel ? 'updated' : 'created'}.`);
setDialogOpen(false);
fetchLabels();
onLabelsChanged?.();
} catch (err: unknown) {
toast.error((err as Error)?.message || 'Something went wrong.');
} finally {
@@ -116,6 +121,7 @@ export function LabelsSection() {
toast.success('Label deleted.');
setDeleteTarget(null);
fetchLabels();
onLabelsChanged?.();
} catch (err: unknown) {
toast.error((err as Error)?.message || 'Something went wrong.');
}
@@ -0,0 +1,79 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { LABEL_COLORS, type LabelColor } from '@/components/label-types';
interface LabelInlineCreateFormProps {
onSubmit: (name: string, color: LabelColor) => Promise<void>;
onCancel: () => void;
}
export function LabelInlineCreateForm({ onSubmit, onCancel }: LabelInlineCreateFormProps) {
const [name, setName] = useState('');
const [color, setColor] = useState<LabelColor>('teal');
const [saving, setSaving] = useState(false);
const submit = async () => {
const trimmed = name.trim();
if (!trimmed || saving) return;
setSaving(true);
try {
await onSubmit(trimmed, color);
} catch {
setSaving(false);
}
};
return (
<div
className="px-2 py-2 space-y-2"
onKeyDown={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
>
<Input
placeholder="Label name"
value={name}
onChange={e => setName(e.target.value)}
className="h-7 text-xs font-mono"
maxLength={30}
autoFocus
onKeyDown={e => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); submit(); }
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
}}
/>
<div className="flex flex-wrap gap-1">
{LABEL_COLORS.map(c => (
<button
key={c}
type="button"
aria-label={`Color ${c}`}
className={`w-5 h-5 rounded-full border-2 transition-colors ${c === color ? 'border-foreground' : 'border-transparent hover:border-muted-foreground/30'}`}
style={{ backgroundColor: `var(--label-${c})` }}
onClick={() => setColor(c)}
/>
))}
</div>
<div className="flex gap-1">
<Button
size="sm"
className="h-6 text-xs flex-1"
onClick={submit}
disabled={saving || !name.trim()}
>
{saving ? 'Creating...' : 'Create'}
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={onCancel}
disabled={saving}
>
Cancel
</Button>
</div>
</div>
);
}
@@ -1,13 +1,15 @@
import type { ReactNode } from 'react';
import { Check } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { Check, Plus } from 'lucide-react';
import {
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator,
ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { LabelDot } from '@/components/LabelPill';
import { MAX_LABELS_PER_NODE } from '@/components/label-types';
import { cn } from '@/lib/utils';
import type { MenuGroup, MenuItem, StackMenuCtx } from './sidebar-types';
import { useStackMenuItems } from '@/hooks/useStackMenuItems';
import { LabelInlineCreateForm } from './LabelInlineCreateForm';
interface StackContextMenuProps {
file: string;
@@ -27,32 +29,51 @@ function GroupHeader({ id }: { id: string }) {
}
function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
const [creating, setCreating] = useState(false);
return (
<ContextMenuSub>
<ContextMenuSub onOpenChange={open => { if (!open) setCreating(false); }}>
<ContextMenuSubTrigger>
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
{item.label}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
{ctx.labels.length === 0 && (
<ContextMenuItem disabled>
<span className="text-xs text-muted-foreground">No labels yet</span>
</ContextMenuItem>
)}
{ctx.labels.map(label => {
const assigned = ctx.assignedLabelIds.includes(label.id);
return (
<ContextMenuItem key={label.id} onClick={() => ctx.toggleLabel(label.id)}>
<LabelDot color={label.color} />
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
<ContextMenuSubContent className="min-w-[200px]">
{creating ? (
<LabelInlineCreateForm
onSubmit={async (name, color) => {
await ctx.createAndAssignLabel(name, color);
setCreating(false);
}}
onCancel={() => setCreating(false)}
/>
) : (
<>
{ctx.labels.length === 0 && (
<ContextMenuItem disabled>
<span className="text-xs text-muted-foreground">No labels yet</span>
</ContextMenuItem>
)}
{ctx.labels.map(label => {
const assigned = ctx.assignedLabelIds.includes(label.id);
return (
<ContextMenuItem key={label.id} onClick={() => ctx.toggleLabel(label.id)}>
<LabelDot color={label.color} />
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
</ContextMenuItem>
);
})}
<ContextMenuSeparator />
{ctx.labels.length < MAX_LABELS_PER_NODE && (
<ContextMenuItem onSelect={e => { e.preventDefault(); setCreating(true); }}>
<Plus className="w-3.5 h-3.5 mr-2 text-muted-foreground" strokeWidth={1.5} />
<span className="text-xs">New label</span>
</ContextMenuItem>
)}
<ContextMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</ContextMenuItem>
);
})}
<ContextMenuSeparator />
<ContextMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</ContextMenuItem>
</>
)}
</ContextMenuSubContent>
</ContextMenuSub>
);
@@ -1,13 +1,16 @@
import { MoreVertical, Check } from 'lucide-react';
import { useState } from 'react';
import { MoreVertical, Check, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { LabelDot } from '@/components/LabelPill';
import { MAX_LABELS_PER_NODE } from '@/components/label-types';
import { cn } from '@/lib/utils';
import type { MenuGroup, MenuItem, StackMenuCtx } from './sidebar-types';
import { useStackMenuItems } from '@/hooks/useStackMenuItems';
import { LabelInlineCreateForm } from './LabelInlineCreateForm';
interface StackKebabMenuProps {
file: string;
@@ -26,32 +29,51 @@ function GroupHeader({ id }: { id: string }) {
}
function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
const [creating, setCreating] = useState(false);
return (
<DropdownMenuSub>
<DropdownMenuSub onOpenChange={open => { if (!open) setCreating(false); }}>
<DropdownMenuSubTrigger>
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
{item.label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
{ctx.labels.length === 0 && (
<DropdownMenuItem disabled>
<span className="text-xs text-muted-foreground">No labels yet</span>
</DropdownMenuItem>
)}
{ctx.labels.map(label => {
const assigned = ctx.assignedLabelIds.includes(label.id);
return (
<DropdownMenuItem key={label.id} onSelect={(e) => { e.preventDefault(); ctx.toggleLabel(label.id); }}>
<LabelDot color={label.color} />
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
<DropdownMenuSubContent className="min-w-[200px]">
{creating ? (
<LabelInlineCreateForm
onSubmit={async (name, color) => {
await ctx.createAndAssignLabel(name, color);
setCreating(false);
}}
onCancel={() => setCreating(false)}
/>
) : (
<>
{ctx.labels.length === 0 && (
<DropdownMenuItem disabled>
<span className="text-xs text-muted-foreground">No labels yet</span>
</DropdownMenuItem>
)}
{ctx.labels.map(label => {
const assigned = ctx.assignedLabelIds.includes(label.id);
return (
<DropdownMenuItem key={label.id} onSelect={(e) => { e.preventDefault(); ctx.toggleLabel(label.id); }}>
<LabelDot color={label.color} />
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
{ctx.labels.length < MAX_LABELS_PER_NODE && (
<DropdownMenuItem onSelect={e => { e.preventDefault(); setCreating(true); }}>
<Plus className="w-3.5 h-3.5 mr-2 text-muted-foreground" strokeWidth={1.5} />
<span className="text-xs">New label</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
@@ -1,5 +1,5 @@
import type { LucideIcon } from 'lucide-react';
import type { Label } from '../label-types';
import type { Label, LabelColor } from '../label-types';
export type MenuGroupId = 'inspect' | 'organize' | 'lifecycle' | 'destructive';
@@ -43,6 +43,7 @@ export interface StackMenuCtx {
pin: () => void;
unpin: () => void;
toggleLabel: (labelId: number) => void;
createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>;
openLabelManager: () => void;
}
+10 -8
View File
@@ -42,14 +42,16 @@ const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-glass-border bg-popover p-1 text-popover-foreground shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-glass-border bg-popover p-1 text-popover-foreground shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
@@ -27,6 +27,7 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
pin: vi.fn(),
unpin: vi.fn(),
toggleLabel: vi.fn(),
createAndAssignLabel: vi.fn(),
openLabelManager: vi.fn(),
...overrides,
};