import { useEffect, useRef, useState } from "react";
import {
Avatar,
AvatarFallback,
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@godxjp/ui/data-display";
import {
ChatComposer,
ChatSuggestion,
FormField,
Select,
type ChatSuggestionItemProp,
} from "@godxjp/ui/data-entry";
import { Button, Text } from "@godxjp/ui/general";
import { useTranslation } from "@godxjp/ui/i18n";
import { isApplePlatform } from "@godxjp/ui/lib/utils";
import {
AppShell,
Flex,
PageContainer,
ResponsiveGrid,
Sidebar,
type SidebarSectionProp,
Topbar,
} from "@godxjp/ui/layout";
import { Bot, MessageSquare, Paperclip, Settings, Smile, Sparkles, Users } from "lucide-react";
/**
* ChatComposer — the message input of a conversation, and ChatSuggestion, the trigger-character
* list over it.
*
* The first card is the real screen: a live assistant transcript whose composer sends, streams and
* cancels, with `/` slash commands and `@` mentions wired to the same draft. Every card after it
* exists so the whole API is visible AT REST — every `submitType` value (including the record-
* comment bar: `modEnter` + `allowEmptySubmit`), all four `size` steps,
* every slot, and each non-default state — without having to click anything.
*
* Composed only from real @godxjp/ui components.
*/
const sections: SidebarSectionProp[] = [
{
label: "アシスタント",
items: [
{ id: "chat", label: "チャット", icon: MessageSquare },
{ id: "agents", label: "エージェント", icon: Bot },
{ id: "members", label: "メンバー", icon: Users },
],
},
{ label: "管理", items: [{ id: "settings", label: "設定", icon: Settings }] },
];
/** Slash commands — one level of `children` on the template row. */
const COMMANDS: ChatSuggestionItemProp[] = [
{ value: "summarize", label: "要約する", description: "このスレッドを3行で要約します" },
{ value: "translate", label: "翻訳する", description: "英語 ⇄ 日本語" },
{ value: "explain", label: "詳しく説明する" },
{
value: "template",
label: "テンプレート",
description: "定型文を挿入します",
children: [
{ value: "template/meeting", label: "議事録", description: "日時・出席者・決定事項" },
{ value: "template/report", label: "週報", description: "実績・課題・来週の予定" },
],
},
{ value: "archive", label: "アーカイブ", description: "権限がありません", disabled: true },
];
/** Mention list — the same component with a different `triggerCharacter`. */
const MEMBERS: ChatSuggestionItemProp[] = [
{ value: "sato", label: "佐藤 花子", description: "経理部" },
{ value: "tanaka", label: "田中 太郎", description: "営業部" },
{ value: "yamada", label: "山田 一郎", description: "開発部" },
];
interface Message {
id: number;
author: "you" | "assistant";
body: string;
}
const OPENING: Message[] = [
{ id: 1, author: "you", body: "先月の交際費の内訳を教えてください。" },
{
id: 2,
author: "assistant",
body: "先月の交際費は 342,000 円でした。会食が 210,000 円、贈答が 132,000 円です。",
},
];
/** Record statuses for the comment bar — a status change is postable without any text. */
const STATUSES = [
{ value: "open", label: "未対応" },
{ value: "inProgress", label: "処理中" },
{ value: "resolved", label: "処理済み" },
{ value: "closed", label: "完了" },
];
interface Comment {
id: number;
body: string;
status: string | null;
}
/** One transcript line. The message FEED is ChatBubbleList's job; here it only sets the scene. */
function Line({ message }: { message: Message }) {
const mine = message.author === "you";
return (
{mine ? "私" : "AI"}
{mine ? "あなた" : "アシスタント"}
{message.body}
);
}
export default function Demo() {
const { t } = useTranslation();
// ── Card 1: the live screen ───────────────────────────────────────────────────────────────
const [messages, setMessages] = useState(OPENING);
const [draft, setDraft] = useState("");
const [streaming, setStreaming] = useState(false);
const timer = useRef(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
function send(text: string) {
setMessages((current) => [...current, { id: current.length + 1, author: "you", body: text }]);
setDraft("");
setStreaming(true);
// A real delay, so the cancel affordance is reachable instead of resolving instantly.
timer.current = window.setTimeout(() => {
setStreaming(false);
setMessages((current) => [
...current,
{ id: current.length + 1, author: "assistant", body: "確認しました。集計を開始します。" },
]);
}, 2500);
}
function cancel() {
window.clearTimeout(timer.current);
setStreaming(false);
}
// ── Card 2: both submitType values, each holding its own draft ────────────────────────────
const [enterDraft, setEnterDraft] = useState("Enter を押すと送信されます");
const [shiftDraft, setShiftDraft] = useState("Enter は改行、Shift + Enter で送信");
const [lastSent, setLastSent] = useState("—");
// ── Card 2b: the record-comment bar (modEnter + allowEmptySubmit) ──────────────────────────
const [commentDraft, setCommentDraft] = useState("");
const [status, setStatus] = useState("open");
const [savedStatus, setSavedStatus] = useState("open");
const [comments, setComments] = useState([]);
function postComment(text: string) {
const changed = status !== savedStatus;
// An empty draft with no status change carries nothing — the consumer decides, not the box.
if (!text && !changed) return;
setComments((current) => [
...current,
{ id: current.length + 1, body: text, status: changed ? status : null },
]);
setSavedStatus(status);
setCommentDraft("");
}
// ── Card 3: the four size steps ───────────────────────────────────────────────────────────
const [sizeDrafts, setSizeDrafts] = useState>({
xs: "xs",
sm: "sm",
md: "md",
lg: "lg",
});
// ── Card 4: every slot filled ─────────────────────────────────────────────────────────────
const [slotDraft, setSlotDraft] = useState("見積書のドラフトを作成してください");
// ── Card 5: the non-default states ────────────────────────────────────────────────────────
const [errorDraft, setErrorDraft] = useState("");
const [warningDraft, setWarningDraft] = useState("送信先が未確定です");
const [countedDraft, setCountedDraft] = useState("最大 120 文字まで入力できます");
// ── Card 6: mentions ──────────────────────────────────────────────────────────────────────
const [mentionDraft, setMentionDraft] = useState("");
return (
{}}
product={{ name: "CoreDesk", role: "アシスタント", color: "hsl(var(--primary))" }}
/>
}
topbar={
C
}
/>
}
>
{/* ── 1. The real screen ───────────────────────────────────────────────────────── */}
経理アシスタント
送信すると 2.5 秒間ストリーミングします。その間、送信ボタンは停止ボタンに
置き換わります(同時に2つは出ません)。「/」でコマンド一覧が開きます。
{messages.map((message) => (
))}
{streaming ? (
生成中…
) : null}
setDraft(`/${value} `)}>
{({ onTrigger, onKeyDown }) => (
{
setDraft(next);
onTrigger(next);
}}
onKeyDown={onKeyDown}
onSubmit={send}
loading={streaming}
onCancel={cancel}
placeholder="メッセージを入力"
prefix={
}
footer={
Enter で送信 · Shift + Enter で改行
}
/>
)}
{/* ── 2. submitType, both values ───────────────────────────────────────────────── */}
submitType · どのキーで送るか
左は「enter」(既定)、右は「shiftEnter」。日本語変換中の Enter は
どちらでも送信になりません。最後に送信された文面:{lastSent}
{
setLastSent(text);
setEnterDraft("");
}}
footer={
Enter で送信 · Shift + Enter で改行
}
/>
{
setLastSent(text);
setShiftDraft("");
}}
footer={
Shift + Enter で送信 · Enter で改行
}
/>
{/* ── 2b. Record comment bar ───────────────────────────────────────────────────── */}
submitType="modEnter" · allowEmptySubmit · 課題へのコメント
Enter と Shift + Enter は改行、⌘ + Enter(Mac)/ Ctrl + Enter(Windows・Linux)で
投稿します。allowEmptySubmit により本文が空でも送信でき、ステータスだけを
変更できます(onSubmit には空文字が渡ります)。
{comments.length === 0 ? (
まだコメントはありません
) : (
comments.map((comment) => (
{comment.status ? (
ステータス:
{STATUSES.find((option) => option.value === comment.status)?.label}
) : null}
{comment.body ? {comment.body} : null}
))
)}
{t("dataEntry.chatComposer.hintModEnter", {
modifier: isApplePlatform() ? "⌘" : "Ctrl",
})}
}
/>
{/* ── 3. Every size step ───────────────────────────────────────────────────────── */}
size · xs / sm / md / lg
size は --control-height の段位を切り替えます。送信ボタンの一辺と、下書き欄の
下限(1行ぶん)・上限(その5倍)がこの段位から決まります。上限を超えると枠は
伸びず、下書き欄の中でスクロールします。1行だけの状態では下限より本文の行の高さ
が勝つため、枠の高さは4段とも同じです。これは Textarea と同じ挙動です。
{(["xs", "sm", "md", "lg"] as const).map((step) => (
setSizeDrafts((current) => ({ ...current, [step]: next }))
}
placeholder={`size="${step}"`}
/>
))}
{/* ── 4. Every slot ────────────────────────────────────────────────────────────── */}
スロット · header / prefix / actions / footer
header は入力欄の上、prefix は行頭、actions は送信ボタンの手前、footer は下。
アイコンだけのボタンには必ずアクセシブル名を付けます。
見積書_v3.pdf
添付 1件
}
prefix={
}
actions={
}
footer={
社外秘の情報は入力しないでください
}
/>
{/* ── 5. Non-default states ────────────────────────────────────────────────────── */}
状態 · error / warning / disabled / readOnly / maxLength
status="error" は色だけでなく aria-invalid も立てます(WCAG 1.4.1)。
空白だけの下書きでは送信ボタンは無効のまま、名前は保持されます。
送信先を選択すると警告は消えます
}
/>
{countedDraft.length} / 120
}
/>
{/* ── 6. ChatSuggestion with a different trigger ───────────────────────────────── */}
ChatSuggestion · triggerCharacter="@"
同じコンポーネントで、開くきっかけの文字だけを変えたメンション一覧。
「@」を単語の先頭で入力すると開き、↑↓ で移動、Enter か Tab で確定、Escape
で閉じます(入力中の文面は消えません)。
setMentionDraft((current) => `${current}${value} `)}
>
{({ onTrigger, onKeyDown }) => (
{
setMentionDraft(next);
onTrigger(next);
}}
onKeyDown={onKeyDown}
placeholder="「@」でメンバー"
/>
)}
);
}