Files
Jyotisha/frontend/src/components/chat-profile-picker.tsx
T
jesse-uxandClaude Code 36a737616c
Independent Staging Quality Gate / validate (push) Failing after 12m24s
Independent Staging Quality Gate / publish (push) Skipped
fix(frontend): repair staging breakpoint and focus contracts
Merge entry overflow rules into the existing 480px breakpoint, restore picker focus without scrolling, and sync composer and entry contracts without changing page logic. Add AST generation-state guard and both picker close-path regressions.

Validation: targeted 33/33; Chrome isolated components 145 checks; tsc and lint zero errors. Node22 full suite 3706 tests, no lost names or new failures; three target failures fixed, 88 baseline environment/contract failures unchanged. Windows build remains blocked by symlink EPERM. Changed-file privacy check: 11 files, no findings, not a full-repository pass. BUG-1011 remains separate.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 16:31:10 +08:00

193 lines
6.0 KiB
TypeScript

"use client";
import { Popover } from "@base-ui/react/popover";
import { Check } from "lucide-react";
import { useId, useRef, useState } from "react";
import {
chatProfileChoice,
chatProfileDisplayName,
chatProfilePickerRows,
chatProfileSelectedId,
chatSessionSubjectLocked,
type ChatProfileCatalogStatus,
type ChatProfilePickerRow,
} from "@/lib/chat-profile-picker-model";
import type { ChartLibraryRecord, ChatSession } from "@/lib/home-types";
export type ChatProfilePickerProps = {
readonly session: ChatSession;
readonly library: readonly ChartLibraryRecord[];
readonly status: ChatProfileCatalogStatus;
readonly requestInFlight: boolean;
readonly onBindEmpty: (chartProfileId: string) => Promise<boolean>;
readonly onOpenNew: (chartProfileId: string) => Promise<boolean>;
readonly onReload: () => void;
};
export function ChatProfilePicker({
session,
library,
status,
requestInFlight,
onBindEmpty,
onOpenNew,
onReload,
}: ChatProfilePickerProps) {
const [open, setOpen] = useState(false);
const [pendingId, setPendingId] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const listId = useId();
const label = chatProfileDisplayName({ session, status, library });
const selectedId = chatProfileSelectedId(session);
const rows = chatProfilePickerRows({ status, library, selectedId });
const locked = chatSessionSubjectLocked(session);
const deleted = status === "ready" && Boolean(selectedId) && selectedId !== "self" && !rows.some((row) => row.id === selectedId);
function closePicker() {
setOpen(false);
triggerRef.current?.focus({ preventScroll: true });
}
async function choose(row: ChatProfilePickerRow) {
if (!row.complete || pendingId) return;
const choice = chatProfileChoice({
session,
chartProfileId: row.id,
requestInFlight,
libraryReady: status === "ready",
selectable: row.complete,
});
if (choice.action === "ignore") {
if (row.selected) closePicker();
return;
}
setPendingId(row.id);
try {
const ok = choice.action === "bind"
? await onBindEmpty(choice.chartProfileId)
: await onOpenNew(choice.chartProfileId);
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" ? <p className="chat-profile-picker-status" role="status">人物列表还在读</p> : 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}
{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>
);
}