Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import { MessageSquare, Pencil, Plus, Trash2, X } from "lucide-react";
|
|
import React, { useCallback, useMemo, useState } from "react";
|
|
import type { AIQuickMessage } from "../../../../infrastructure/ai/quickMessages";
|
|
import {
|
|
createQuickMessageId,
|
|
isValidQuickMessageSlug,
|
|
normalizeQuickMessageSlug,
|
|
QUICK_MESSAGE_LIMITS,
|
|
slugFromQuickMessageName,
|
|
} from "../../../../infrastructure/ai/quickMessages";
|
|
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
|
import { Button } from "../../../ui/button";
|
|
import { SettingCard, SettingsSection } from "../../settings-ui";
|
|
|
|
interface QuickMessagesSettingsProps {
|
|
quickMessages: AIQuickMessage[];
|
|
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
|
|
reservedUserSkillSlugs?: string[];
|
|
}
|
|
|
|
type DraftQuickMessage = {
|
|
name: string;
|
|
slug: string;
|
|
content: string;
|
|
description: string;
|
|
};
|
|
|
|
const emptyDraft = (): DraftQuickMessage => ({
|
|
name: "",
|
|
slug: "",
|
|
content: "",
|
|
description: "",
|
|
});
|
|
|
|
export const QuickMessagesSettings: React.FC<QuickMessagesSettingsProps> = ({
|
|
quickMessages,
|
|
setQuickMessages,
|
|
reservedUserSkillSlugs = [],
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [draft, setDraft] = useState<DraftQuickMessage>(emptyDraft);
|
|
const [slugTouched, setSlugTouched] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const sortedMessages = useMemo(
|
|
() => [...quickMessages].sort((a, b) => a.name.localeCompare(b.name)),
|
|
[quickMessages],
|
|
);
|
|
|
|
const resetEditor = useCallback(() => {
|
|
setEditingId(null);
|
|
setIsCreating(false);
|
|
setDraft(emptyDraft());
|
|
setSlugTouched(false);
|
|
setError(null);
|
|
}, []);
|
|
|
|
const beginCreate = useCallback(() => {
|
|
setEditingId(null);
|
|
setIsCreating(true);
|
|
setDraft(emptyDraft());
|
|
setSlugTouched(false);
|
|
setError(null);
|
|
}, []);
|
|
|
|
const beginEdit = useCallback((message: AIQuickMessage) => {
|
|
setIsCreating(false);
|
|
setEditingId(message.id);
|
|
setDraft({
|
|
name: message.name,
|
|
slug: message.slug,
|
|
content: message.content,
|
|
description: message.description ?? "",
|
|
});
|
|
setSlugTouched(true);
|
|
setError(null);
|
|
}, []);
|
|
|
|
const handleNameChange = useCallback((name: string) => {
|
|
setDraft((prev) => ({
|
|
...prev,
|
|
name,
|
|
slug: slugTouched ? prev.slug : slugFromQuickMessageName(name),
|
|
}));
|
|
}, [slugTouched]);
|
|
|
|
const handleSlugChange = useCallback((slug: string) => {
|
|
setSlugTouched(true);
|
|
setDraft((prev) => ({ ...prev, slug: normalizeQuickMessageSlug(slug) }));
|
|
}, []);
|
|
|
|
const validateDraft = useCallback((nextDraft: DraftQuickMessage, excludeId?: string | null): string | null => {
|
|
const name = nextDraft.name.trim();
|
|
const slug = normalizeQuickMessageSlug(nextDraft.slug);
|
|
const content = nextDraft.content.trim();
|
|
|
|
if (!name) return t("ai.quickMessages.error.nameRequired");
|
|
if (!isValidQuickMessageSlug(slug)) return t("ai.quickMessages.error.invalidSlug");
|
|
if (!content) return t("ai.quickMessages.error.contentRequired");
|
|
|
|
if (!excludeId && quickMessages.length >= QUICK_MESSAGE_LIMITS.maxItems) {
|
|
return t("ai.quickMessages.error.maxItems", { max: String(QUICK_MESSAGE_LIMITS.maxItems) });
|
|
}
|
|
|
|
const slugTaken = quickMessages.some(
|
|
(message) => message.slug === slug && message.id !== excludeId,
|
|
);
|
|
if (slugTaken) return t("ai.quickMessages.error.slugTaken");
|
|
|
|
const skillConflict = reservedUserSkillSlugs.some((skillSlug) => skillSlug === slug);
|
|
if (skillConflict) {
|
|
return t("ai.quickMessages.error.slugConflictsWithSkill", { slug });
|
|
}
|
|
|
|
return null;
|
|
}, [quickMessages, reservedUserSkillSlugs, t]);
|
|
|
|
const handleSave = useCallback(() => {
|
|
const validationError = validateDraft(draft, editingId);
|
|
if (validationError) {
|
|
setError(validationError);
|
|
return;
|
|
}
|
|
|
|
const payload: AIQuickMessage = {
|
|
id: editingId ?? createQuickMessageId(),
|
|
name: draft.name.trim(),
|
|
slug: normalizeQuickMessageSlug(draft.slug),
|
|
content: draft.content.trim(),
|
|
description: draft.description.trim() || undefined,
|
|
};
|
|
|
|
if (editingId) {
|
|
setQuickMessages((prev) => prev.map((message) => (
|
|
message.id === editingId ? payload : message
|
|
)));
|
|
} else {
|
|
setQuickMessages((prev) => [...prev, payload]);
|
|
}
|
|
resetEditor();
|
|
}, [draft, editingId, resetEditor, setQuickMessages, validateDraft]);
|
|
|
|
const handleDelete = useCallback((message: AIQuickMessage) => {
|
|
const ok = window.confirm(t("ai.quickMessages.confirmDelete", { name: message.name }));
|
|
if (!ok) return;
|
|
setQuickMessages((prev) => prev.filter((item) => item.id !== message.id));
|
|
if (editingId === message.id) {
|
|
resetEditor();
|
|
}
|
|
}, [editingId, resetEditor, setQuickMessages, t]);
|
|
|
|
const showEditor = isCreating || editingId != null;
|
|
|
|
return (
|
|
<SettingsSection
|
|
anchorId="ai-quick-messages"
|
|
title={t("ai.quickMessages.title")}
|
|
actions={(
|
|
<Button variant="outline" size="sm" onClick={beginCreate} disabled={showEditor}>
|
|
<Plus size={14} className="mr-2" />
|
|
{t("ai.quickMessages.add")}
|
|
</Button>
|
|
)}
|
|
>
|
|
<SettingCard padded className="space-y-3">
|
|
<p className="text-xs text-muted-foreground/80 leading-5">
|
|
{t("ai.quickMessages.description")}
|
|
</p>
|
|
|
|
{showEditor ? (
|
|
<div className="rounded-md border border-border/60 bg-background/40 p-4 space-y-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-sm font-medium">
|
|
{isCreating ? t("ai.quickMessages.createTitle") : t("ai.quickMessages.editTitle")}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={resetEditor}
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/30 hover:text-foreground transition-colors"
|
|
aria-label={t("common.cancel")}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<label className="space-y-1.5 text-sm">
|
|
<span className="text-muted-foreground">{t("ai.quickMessages.name")}</span>
|
|
<input
|
|
value={draft.name}
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
|
placeholder={t("ai.quickMessages.name.placeholder")}
|
|
maxLength={QUICK_MESSAGE_LIMITS.name}
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
/>
|
|
</label>
|
|
<label className="space-y-1.5 text-sm">
|
|
<span className="text-muted-foreground">{t("ai.quickMessages.slug")}</span>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground/70">/</span>
|
|
<input
|
|
value={draft.slug}
|
|
onChange={(e) => handleSlugChange(e.target.value)}
|
|
placeholder={t("ai.quickMessages.slug.placeholder")}
|
|
maxLength={QUICK_MESSAGE_LIMITS.slug}
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
|
|
/>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<label className="block space-y-1.5 text-sm">
|
|
<span className="text-muted-foreground">{t("ai.quickMessages.descriptionField")}</span>
|
|
<input
|
|
value={draft.description}
|
|
onChange={(e) => setDraft((prev) => ({ ...prev, description: e.target.value }))}
|
|
placeholder={t("ai.quickMessages.descriptionField.placeholder")}
|
|
maxLength={QUICK_MESSAGE_LIMITS.description}
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
/>
|
|
</label>
|
|
|
|
<label className="block space-y-1.5 text-sm">
|
|
<span className="text-muted-foreground">{t("ai.quickMessages.content")}</span>
|
|
<textarea
|
|
value={draft.content}
|
|
onChange={(e) => setDraft((prev) => ({ ...prev, content: e.target.value }))}
|
|
placeholder={t("ai.quickMessages.content.placeholder")}
|
|
rows={5}
|
|
maxLength={QUICK_MESSAGE_LIMITS.content}
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-y min-h-[120px]"
|
|
/>
|
|
</label>
|
|
|
|
{error ? (
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
) : null}
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" size="sm" onClick={resetEditor}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button size="sm" onClick={handleSave}>
|
|
{t("common.save")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{sortedMessages.length > 0 ? (
|
|
<div className="border-t border-border/60 divide-y divide-border/60">
|
|
{sortedMessages.map((message) => (
|
|
<div
|
|
key={message.id}
|
|
className="py-3"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<MessageSquare size={14} className="text-muted-foreground shrink-0" />
|
|
<span className="text-sm font-medium">{message.name}</span>
|
|
<span className="text-xs font-mono text-muted-foreground/80">/{message.slug}</span>
|
|
</div>
|
|
{message.description ? (
|
|
<p className="text-xs text-muted-foreground leading-5">{message.description}</p>
|
|
) : null}
|
|
<p className="text-xs text-muted-foreground/70 line-clamp-2 whitespace-pre-wrap">
|
|
{message.content}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
|
onClick={() => beginEdit(message)}
|
|
aria-label={t("ai.quickMessages.editTitle")}
|
|
>
|
|
<Pencil size={14} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
onClick={() => handleDelete(message)}
|
|
aria-label={t("ai.quickMessages.confirmDelete", { name: message.name })}
|
|
>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : !showEditor ? (
|
|
<div className="border-t border-border/60 pt-3 text-sm text-muted-foreground">
|
|
<p className="text-sm text-muted-foreground">{t("ai.quickMessages.empty")}</p>
|
|
</div>
|
|
) : null}
|
|
</SettingCard>
|
|
</SettingsSection>
|
|
);
|
|
};
|