Files
multica/packages/views/issues/components/labels-panel.tsx
Naiyuan Qing dce51e3a27 fix(views): guard IME composition on Enter-to-submit handlers (#2207)
* fix(views): guard IME composition on Enter-to-submit handlers

Chinese/Japanese/Korean IMEs use Enter to commit a multi-key
composition. When that Enter also triggers a submit/create handler,
the form fires before the user has finished typing.

Add a shared `isImeComposing` predicate in @multica/core/utils that
checks both `nativeEvent.isComposing` and `keyCode === 229` (Safari
clears isComposing on the commit keydown but keyCode stays 229).
Apply the guard to every Enter→action handler in packages/views where
the input can hold IME text: workspace name, agent name/description,
skill name, label name/edit, mention suggestion picker, property
picker search, delete-workspace typed confirmation.

Tiptap submit-shortcut already guards via `view.composing`; left as is.
Skipped numeric/email/URL/file-path inputs where IME does not apply.

Co-authored-by: multica-agent <github@multica.ai>

* style(agents): align Escape handling with early return in inspector

Three onKeyDown handlers in agent-detail-inspector.tsx now follow the same
shape as labels-panel: handle Escape with an explicit return, then the IME
guard, then Enter submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:17:35 +08:00

299 lines
11 KiB
TypeScript

"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Trash2, Plus, Pencil, Check, X } from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
import { Label as UILabel } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@multica/ui/components/ui/alert-dialog";
import { toast } from "sonner";
import { useWorkspaceId } from "@multica/core/hooks";
import { labelListOptions, useCreateLabel, useUpdateLabel, useDeleteLabel } from "@multica/core/labels";
import type { Label } from "@multica/core/types";
import { isImeComposing } from "@multica/core/utils";
import { LabelChip } from "../../labels/label-chip";
import { useT } from "../../i18n";
/** Default color for brand-new labels. Everything else goes through the native picker. */
const DEFAULT_COLOR_DEFAULT = "#3b82f6";
/**
* Workspace-wide labels management surface. Opened from the Manage labels…
* footer in the label picker.
*/
export function LabelsPanel() {
const { t } = useT("issues");
const wsId = useWorkspaceId();
const { data: labels = [], isLoading } = useQuery(labelListOptions(wsId));
const create = useCreateLabel();
const update = useUpdateLabel();
const del = useDeleteLabel();
const [newName, setNewName] = useState("");
const [newColor, setNewColor] = useState(DEFAULT_COLOR_DEFAULT);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editColor, setEditColor] = useState("");
const [editError, setEditError] = useState("");
const [pendingDeletion, setPendingDeletion] = useState<Label | null>(null);
const handleCreate = () => {
const name = newName.trim();
if (!name) return;
create.mutate(
{ name, color: newColor },
{
onSuccess: () => {
setNewName("");
setNewColor(DEFAULT_COLOR_DEFAULT);
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : t(($) => $.labels_panel.create_failed));
},
},
);
};
const startEdit = (label: Label) => {
setEditingId(label.id);
setEditName(label.name);
setEditColor(label.color);
setEditError("");
};
const cancelEdit = () => {
setEditingId(null);
setEditName("");
setEditColor("");
setEditError("");
};
const saveEdit = (id: string) => {
const name = editName.trim();
if (!name) {
// Surface the reason the save didn't happen — previously this was a
// silent no-op. Button is also disabled (below) but a visible message
// beats a greyed-out button for telling the user WHY.
setEditError(t(($) => $.labels_panel.name_required));
return;
}
setEditError("");
update.mutate(
{ id, name, color: editColor },
{
onSuccess: cancelEdit,
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : t(($) => $.labels_panel.update_failed));
},
},
);
};
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{t(($) => $.labels_panel.intro)}
</p>
{/* Create form — color swatch, name, Add button all in one row */}
<div className="flex items-center gap-2">
<ColorPalette value={newColor} onChange={setNewColor} compact />
<Input
id="label-new-name"
placeholder={t(($) => $.labels_panel.new_placeholder)}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (isImeComposing(e)) return;
if (e.key === "Enter") handleCreate();
}}
className="flex-1"
maxLength={32}
aria-label={t(($) => $.labels_panel.new_aria)}
/>
<Button onClick={handleCreate} disabled={!newName.trim() || create.isPending}>
<Plus className="h-4 w-4 mr-1" />
{t(($) => $.labels_panel.add_action)}
</Button>
</div>
{/* List — scrolls when labels exceed viewport */}
<div className="space-y-2 max-h-[50vh] overflow-y-auto">
{isLoading && <p className="text-sm text-muted-foreground">{t(($) => $.labels_panel.loading)}</p>}
{!isLoading && labels.length === 0 && (
<p className="text-sm text-muted-foreground">{t(($) => $.labels_panel.empty)}</p>
)}
{labels.map((label) => {
const isEditing = editingId === label.id;
const editNameEmpty = isEditing && !editName.trim();
return (
<div
key={label.id}
className="rounded-md border bg-card px-3 py-2"
>
<div className="flex items-center gap-3">
{isEditing ? (
<>
<Input
value={editName}
onChange={(e) => {
setEditName(e.target.value);
if (e.target.value.trim()) setEditError("");
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelEdit();
return;
}
if (isImeComposing(e)) return;
if (e.key === "Enter") saveEdit(label.id);
}}
className="flex-1 h-8"
maxLength={32}
autoFocus
aria-invalid={editNameEmpty}
aria-describedby={editError ? `label-edit-error-${label.id}` : undefined}
/>
<ColorPalette
value={editColor}
onChange={setEditColor}
compact
/>
<Button
size="sm"
variant="ghost"
onClick={() => saveEdit(label.id)}
disabled={editNameEmpty || update.isPending}
aria-label={t(($) => $.labels_panel.save_aria)}
>
<Check className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit} aria-label={t(($) => $.labels_panel.cancel_aria)}>
<X className="h-4 w-4" />
</Button>
</>
) : (
<>
{/* min-w-0 on the label wrapper lets long names wrap without
pushing the hex/buttons off the right edge. */}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<LabelChip label={label} fullName />
<span className="text-xs text-muted-foreground">
{label.color}
</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => startEdit(label)}
aria-label={t(($) => $.labels_panel.edit_aria, { name: label.name })}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setPendingDeletion(label)}
aria-label={t(($) => $.labels_panel.delete_aria, { name: label.name })}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</>
)}
</div>
{isEditing && editError && (
<p
id={`label-edit-error-${label.id}`}
className="mt-1.5 text-xs text-destructive"
role="alert"
>
{editError}
</p>
)}
</div>
);
})}
</div>
<AlertDialog open={!!pendingDeletion} onOpenChange={(o) => !o && setPendingDeletion(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t(($) => $.labels_panel.delete_dialog_title)}</AlertDialogTitle>
<AlertDialogDescription>
{t(($) => $.labels_panel.delete_dialog_desc_prefix)}<strong>{pendingDeletion?.name}</strong>{t(($) => $.labels_panel.delete_dialog_desc_suffix)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t(($) => $.labels_panel.delete_dialog_cancel)}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (!pendingDeletion) return;
del.mutate(pendingDeletion.id, {
onSuccess: () => setPendingDeletion(null),
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : t(($) => $.labels_panel.delete_failed));
},
});
}}
>
{t(($) => $.labels_panel.delete_dialog_confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
/**
* Color picker — a single swatch that opens the browser's native color
* picker. Full gamut, trusted UX, zero visual clutter. `focus-within` ring
* makes keyboard focus visible despite the transparent `<input type="color">`.
*/
function ColorPalette({
value,
onChange,
compact,
}: {
value: string;
onChange: (c: string) => void;
compact?: boolean;
}) {
const { t } = useT("issues");
const size = compact ? "h-7 w-7" : "h-9 w-9";
return (
<div className={compact ? "flex items-center" : "flex items-center gap-3"}>
{!compact && <UILabel className="text-xs text-muted-foreground">{t(($) => $.labels_panel.color_label)}</UILabel>}
<label
className={`relative inline-flex ${size} cursor-pointer items-center justify-center rounded-full border border-border shadow-sm transition-transform hover:scale-105 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background`}
style={{ backgroundColor: value }}
aria-label={t(($) => $.labels_panel.pick_color_aria)}
title={value}
>
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
{!compact && (
<span className="font-mono text-xs text-muted-foreground">{value}</span>
)}
</div>
);
}