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
を切ると、フィードは先頭のまま開き、新しい要素が増えても動きません。
);
}