Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
182 lines
5.5 KiB
TypeScript
182 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import { Popover } from "@base-ui/react/popover";
|
|
import { Check } from "lucide-react";
|
|
import { useId, useRef, useState } from "react";
|
|
|
|
import {
|
|
chatProfilePickerRows,
|
|
type ChatProfileCatalogStatus,
|
|
type ChatProfilePickerRow,
|
|
} from "@/lib/chat-profile-picker-model";
|
|
import { OrbitWaiting } from "@/components/orbit-waiting";
|
|
import type { ChartLibraryRecord } from "@/lib/home-types";
|
|
|
|
export type SubjectSwitcherProps = {
|
|
readonly library: readonly ChartLibraryRecord[];
|
|
readonly status: ChatProfileCatalogStatus;
|
|
readonly selectedId: string;
|
|
readonly label: string;
|
|
readonly locked?: boolean;
|
|
readonly requestInFlight?: boolean;
|
|
readonly deleted?: boolean;
|
|
readonly onChoose: (row: ChatProfilePickerRow) => Promise<boolean> | boolean;
|
|
readonly onReload: () => void;
|
|
};
|
|
|
|
export function SubjectSwitcher({
|
|
library,
|
|
status,
|
|
selectedId,
|
|
label,
|
|
locked = false,
|
|
requestInFlight = false,
|
|
deleted = false,
|
|
onChoose,
|
|
onReload,
|
|
}: SubjectSwitcherProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
const listId = useId();
|
|
const rows = chatProfilePickerRows({ status, library, selectedId }).slice(0, 5);
|
|
|
|
function closePicker() {
|
|
setOpen(false);
|
|
triggerRef.current?.focus({ preventScroll: true });
|
|
}
|
|
|
|
async function choose(row: ChatProfilePickerRow) {
|
|
if (!row.complete || pendingId) return;
|
|
if (row.selected) closePicker();
|
|
if (row.selected) return;
|
|
setPendingId(row.id);
|
|
try {
|
|
const ok = await onChoose(row);
|
|
if (ok) closePicker();
|
|
} finally {
|
|
setPendingId(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Popover.Root
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next);
|
|
if (!next) triggerRef.current?.focus({ preventScroll: true });
|
|
}}
|
|
>
|
|
<Popover.Trigger
|
|
ref={triggerRef}
|
|
className="chat-header-chart chat-profile-picker-trigger"
|
|
type="button"
|
|
aria-expanded={open}
|
|
aria-controls={listId}
|
|
aria-label={`当前星盘:${label}`}
|
|
>
|
|
<span className="chat-profile-picker-name">{label}</span>
|
|
</Popover.Trigger>
|
|
<Popover.Portal>
|
|
<Popover.Positioner className="chat-profile-picker-positioner" side="bottom" align="start" sideOffset={8} collisionPadding={12}>
|
|
<Popover.Popup
|
|
id={listId}
|
|
className="chat-profile-picker-popup"
|
|
onKeyDown={(event) => {
|
|
if (event.key !== "Escape") return;
|
|
event.preventDefault();
|
|
closePicker();
|
|
}}
|
|
>
|
|
<Popover.Title className="chat-profile-picker-title">当前星盘</Popover.Title>
|
|
{status === "loading" ? <OrbitWaiting className="chat-profile-picker-waiting" label="人物列表还在读" /> : null}
|
|
{status === "failed" ? (
|
|
<div className="chat-profile-picker-status" role="status">
|
|
<p>人物列表暂时读不出来</p>
|
|
<button className="chat-profile-picker-retry" type="button" onClick={onReload}>重试</button>
|
|
</div>
|
|
) : null}
|
|
{status === "degraded" ? (
|
|
<p className="chat-profile-picker-status" role="status">暂时读不到其他人</p>
|
|
) : null}
|
|
{deleted ? <p className="chat-profile-picker-status">这份资料已经删除,这段对话只能看。</p> : null}
|
|
{rows.length > 0 ? (
|
|
<div className="chat-profile-picker-options" role="group" aria-label="当前星盘">
|
|
{rows.map((row) => (
|
|
<ProfileRow
|
|
key={row.id}
|
|
row={row}
|
|
locked={locked}
|
|
requestInFlight={requestInFlight}
|
|
pending={pendingId === row.id}
|
|
onChoose={() => void choose(row)}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</Popover.Popup>
|
|
</Popover.Positioner>
|
|
</Popover.Portal>
|
|
</Popover.Root>
|
|
);
|
|
}
|
|
|
|
function ProfileRow({
|
|
row,
|
|
locked,
|
|
requestInFlight,
|
|
pending,
|
|
onChoose,
|
|
}: {
|
|
readonly row: ChatProfilePickerRow;
|
|
readonly locked: boolean;
|
|
readonly requestInFlight: boolean;
|
|
readonly pending: boolean;
|
|
readonly onChoose: () => void;
|
|
}) {
|
|
if (!row.complete) {
|
|
return (
|
|
<div className="chat-profile-picker-option" aria-disabled="true">
|
|
<span className="chat-profile-picker-copy">
|
|
<b>{row.name}</b>
|
|
<small>{row.detail}</small>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (locked && !row.selected) {
|
|
return (
|
|
<button
|
|
className="chat-profile-picker-option"
|
|
type="button"
|
|
disabled={requestInFlight || pending}
|
|
aria-label={`${row.name},新建会话并使用此人物`}
|
|
onClick={onChoose}
|
|
>
|
|
<span className="chat-profile-picker-copy">
|
|
<b>{row.name}</b>
|
|
<small>{requestInFlight ? "这段回答结束后再换" : "新建会话并使用此人物"}</small>
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
className="chat-profile-picker-option"
|
|
type="button"
|
|
aria-pressed={row.selected}
|
|
data-selected={row.selected ? "" : undefined}
|
|
disabled={pending}
|
|
onClick={onChoose}
|
|
>
|
|
<span className="chat-profile-picker-copy">
|
|
<b>{row.name}</b>
|
|
<small>{row.detail}</small>
|
|
</span>
|
|
<Check className="chat-profile-picker-check" aria-hidden="true" />
|
|
</button>
|
|
);
|
|
}
|