import * as React from "react"; import { Bot, RotateCcw, User } from "lucide-react"; import { Avatar, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ChatBubble, ChatBubbleList, type ChatBubbleProp, type ChatMessageProp, } from "@godxjp/ui/data-display"; import { formatAppTime } from "@godxjp/ui/datetime"; import { Button, Text } from "@godxjp/ui/general"; import { Flex, PageContainer, ResponsiveGrid } from "@godxjp/ui/layout"; /** * ChatBubble · ChatBubbleList — 会話の1発言と、その発言が流れるフィード。 * * ChatBubbleList が持っているのは「一番下に貼りつく」挙動です。読み手がすでに最下部にいる間だけ * 新着に追従し、履歴を読み返そうとスクロールを上げた瞬間に追従をやめて、戻るためのボタンを出しま * す。読んでいる途中で最下部へ引き戻すのは、機械が勝手に文脈を変える操作(WCAG 3.2.5)です。 * * 入力欄は ChatComposer(data-entry)が担当します。このページはフィード側だけを扱います。 */ const ASSISTANT_AVATAR = ( ); const USER_AVATAR = ( ); /** 1つのフィードの見た目を1か所で決める。各メッセージ側の指定が常に優先されます。 */ const ROLES: Record> = { assistant: { placement: "start", variant: "filled", header: "アシスタント", avatar: ASSISTANT_AVATAR, }, user: { placement: "end", variant: "outlined", header: "自分", avatar: USER_AVATAR, }, system: { placement: "start", variant: "borderless", tone: "info", size: "sm", header: "システム", }, }; /** * Times are formatted INSIDE the component, not at module scope: formatAppTime reads the * AppProvider's locale and IANA timezone, and at module-evaluation time that context has not been * synced yet — a module-level call silently formats against the pre-provider default and drifts * from every other time on the page. */ const AT = (hhmm: string) => formatAppTime(`2026-09-10T${hhmm}:00+07:00`); const conversation = (): ChatMessageProp[] => [ { id: "c1", role: "assistant", content: "おはようございます。今日は何をお手伝いしましょうか。", footer: AT("09:02"), }, { id: "c2", role: "user", content: "先週受領した請求書のうち、まだ承認されていないものを一覧にしてください。", footer: AT("09:03"), }, { id: "c3", role: "system", content: "検索ツールを実行しました(対象 128 件 / 該当 7 件)。", }, { id: "c4", role: "assistant", content: "未承認の請求書は 7 件です。合計 1,284,000 円。\n最も古いものは 9月1日受領の株式会社青葉商事 分で、支払期限まであと 3 日です。", footer: AT("09:03"), typing: { step: 2, interval: 40 }, }, { id: "c5", role: "user", content: "その 7 件を承認待ちのまま経理へ回してください。", footer: AT("09:05"), }, { id: "c6", role: "assistant", loading: true, }, ]; /** 上へ戻って読み返せるだけの長さがあるフィード。追従が切れる様子を実際に確かめる用。 */ const LONG_THREAD: ChatMessageProp[] = Array.from({ length: 24 }, (_, index) => ({ id: `t${String(index)}`, role: index % 2 === 0 ? "assistant" : "user", content: index % 2 === 0 ? `${String(index + 1)} 件目の集計が終わりました。差異は見つかりません。` : `${String(index + 1)} 件目もお願いします。`, })); const RTL_THREAD: ChatMessageProp[] = [ { id: "r1", role: "assistant", content: "كيف يمكنني المساعدة؟" }, { id: "r2", role: "user", content: "لخّص فواتير الأسبوع الماضي." }, ]; const LTR_THREAD: ChatMessageProp[] = [ { id: "l1", role: "assistant", content: "How can I help?" }, { id: "l2", role: "user", content: "Summarise last week's invoices." }, ]; const ARCHIVE: ChatMessageProp[] = Array.from({ length: 12 }, (_, index) => ({ id: `a${String(index)}`, role: index % 2 === 0 ? "user" : "assistant", content: index % 2 === 0 ? `${String(index + 1)} 番目の質問です。` : `${String(index + 1)} 番目の回答です。記録として保存されています。`, })); export default function Demo() { // 「新しい会話」を押すと typing がもう一度先頭から流れる。id が変わる = 別の発言。 const [run, setRun] = React.useState(0); const live = React.useMemo( () => conversation().map((m) => (m.id === "c4" ? { ...m, id: `c4-${String(run)}` } : m)), [run], ); return ( アシスタントの会話 roles で assistant / user / system の既定値を1か所に書き、items は中身だけを持ちま す。最後の発言は loading(Skeleton と aria-busy、読み上げ用の文言つき)、その1つ前は typing で1文字ずつ流れます。prefers-reduced-motion: reduce の環境では、アニメーショ ンは動かず全文がそのまま表示されます。時刻は formatAppTime(Intl.DateTimeFormat と AppProvider のタイムゾーン)で整形しています。 入力欄は ChatComposer(data-entry)が担当します。 最下部への貼りつきと、戻るためのボタン 下のフィードを少しだけ上へスクロールしてください。追従がその場で止まり、 フィードの下端に「最新へ移動」ボタンが出ます。離れている間に届いた件数は Intl.PluralRules で数えて文言に入ります。ボタンはフォーカスできる本物のボタンなの で、キーボードだけでも最新へ戻れます。自動で引き戻すことは一度もありません。 placement は論理軸(dir="rtl" で自動的に反転) 同じ items と同じ roles を、左が dir="ltr"、右が dir="rtl" で描 いています。placement="end" の自分の発言は、ltr では右、rtl では左に寄り ます。ロケールで分岐したコードは1行もありません。
variant · 3つすべて variant は構造の軸です。filled は既定の面、outlined は 1px の枠だけ、borderless は 面も枠も持たず本文としてそのまま読ませます。色で状態を示すのは tone の役目で、 variant には状態を表す値がありません。Ant Design X の shadow は、このデザインシステ ムが影を使わないため意図的にありません。 filled · 既定の面。相手の発言に使います。 outlined · 面を持たず 1px の枠だけ。 borderless · 長い回答を本文として読ませたいとき。面も枠もありません。 filled · placement="end" は自分の発言用の色を持ちます。 size · xs / sm / md / lg size は文字の段と内側の余白を同時に動かします。既定は md。"default" という 値はありません。 xs · 監査ログのように密度を上げたいとき。 sm · システムの注記など、会話の脇に置くもの。 md · 既定。ふつうの会話。 lg · 画面いっぱいのアシスタント面で、長文を読ませるとき。 tone · 色だけで状態を伝えない tone は会話そのものではない発言(送信失敗、上限の警告、ツールの結果)のための状態 です。Alert と同じ役割色を薄く敷き、同時に読み上げ用の状態語(情報 / 成功 / 警告 / エラー)を必ず入れます。色を見分けられない読み手にも状態が届きます。 default · 状態なし。ふつうの発言です。 info · モデルを gpt-5 に切り替えました。 success · 7 件の請求書を経理へ回しました。 warning · 今月の利用上限の 90% に達しています。 destructive · 送信できませんでした。もう一度お試しください。 loading と typing loading は「返答を頼んだが、まだ届いていない」状態です。Skeleton の棒は装飾なので 支援技術からは隠し、代わりに aria-busy と読み上げ用の1行を置きます。typing は文字列 の本文だけを流します(ReactNode には流す単位がないので、そのまま全部出ます)。 流れている間の途中の文字は読み上げ対象から外れ、完成した本文だけが1回だけ読み上げ られます。 typing · 1文字ずつ流れます。上の「返答をもう一度流す」でやり直せます。 ReactNode の本文。typing を渡しても流れず、そのまま表示されます。 autoScroll={false} · 読み終えた記録 保管された記録を引用から開くときは、最新の発言に飛ばされたくありません。autoScroll を切ると、フィードは先頭のまま開き、新しい要素が増えても動きません。
); }