[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
305
components/settings/tabs/ai/QuickMessagesSettings.tsx
Normal file
305
components/settings/tabs/ai/QuickMessagesSettings.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user