Files
Jyotisha/frontend/src/components/consultation-run-timeline.tsx
T

207 lines
6.6 KiB
TypeScript

"use client";
import { useId, useState } from "react";
import { BookOpen, Check, ChevronDown, Layers, ListTodo, LoaderCircle, PenLine, type LucideIcon } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
void LoaderCircle; // source lock: tests/chat-stream-layout.test.ts matches /LoaderCircle/
import type {
ConsultationTimelineKind,
ConsultationTimelineRow,
} from "@/lib/consultation-run-timeline";
const KIND_ICONS: Record<ConsultationTimelineKind, LucideIcon> = {
method: BookOpen,
calculate: Layers,
think: ListTodo,
write: PenLine,
};
/** Shown while the request is in flight but no step has been reported yet. */
export const QUEUED_TIMELINE_ROW: ConsultationTimelineRow = {
id: "queued",
kind: "method",
status: "live",
label: "正在处理…",
};
/** Shown while the request is still in flight after completed steps, before the next live step. */
export const ANALYZING_TIMELINE_ROW: ConsultationTimelineRow = {
id: "analyzing",
kind: "think",
status: "live",
label: "正在分析…",
};
export function visibleTimelineRows(
rows: readonly ConsultationTimelineRow[],
live: boolean,
): ConsultationTimelineRow[] {
if (!live) return [...rows];
if (rows.length === 0) return [QUEUED_TIMELINE_ROW];
if (rows.some((row) => row.status === "live")) return [...rows];
return [...rows, ANALYZING_TIMELINE_ROW];
}
export function timelineSummaryLabel(rows: readonly ConsultationTimelineRow[], live: boolean): string {
return live ? "正在分析" : `已完成 ${rows.length} 步`;
}
/**
* The step timeline of one assistant reply. Open by default while live and
* closed once settled; a reader's own toggle wins over both. The element keeps
* its identity across settlement so the collapse is a transition, not a swap.
*/
export function ConsultationRunTimeline({
rows,
live = false,
vargaSentence,
}: Readonly<{
rows: readonly ConsultationTimelineRow[];
live?: boolean;
vargaSentence?: string | null;
}>) {
const bodyId = useId();
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const visibleRows = visibleTimelineRows(rows, live);
const varga = vargaSentence?.trim() ?? "";
if (visibleRows.length === 0 && !varga) return null;
const open = userOpen ?? live;
const summary = timelineSummaryLabel(rows, live);
return (
<section
className={`consultation-run-timeline${open ? " is-open" : ""}${live ? " is-live" : ""}`}
aria-label="分析步骤"
>
<button
type="button"
className="consultation-run-timeline__summary"
aria-expanded={open}
aria-controls={bodyId}
onClick={() => setUserOpen(!open)}
>
<span
key={summary}
className={`consultation-run-timeline__summary-label${live ? " agent-activity-status__text" : ""}`}
>
{summary}
</span>
<ChevronDown className="consultation-run-timeline__chevron" aria-hidden="true" />
</button>
<div className="consultation-run-timeline__body-wrap" id={bodyId} inert={open ? undefined : true}>
<div className="consultation-run-timeline__body-inner">
<ol className="agent-thinking-timeline consultation-run-timeline__list" aria-label="步骤">
{visibleRows.map((row) => (
<TimelineRow key={row.id} row={row} />
))}
{varga ? (
<li className="agent-thinking-step is-done consultation-run-timeline__row">
<span className="agent-thinking-marker" aria-hidden="true">
<Check />
</span>
<span>{varga}</span>
</li>
) : null}
</ol>
</div>
</div>
</section>
);
}
/**
* One live step outside a reply's timeline — the rectification surface uses it
* for the gap between a settled turn and its next question. It is the same
* row as inside the timeline (marker, spinner, shimmer label), not a second
* waiting vocabulary.
*/
export function ConsultationTimelineLiveRow({ label, id = "live" }: Readonly<{ label: string; id?: string }>) {
return (
<ol className="agent-thinking-timeline consultation-run-timeline__list is-standalone" aria-label="进行中">
<TimelineRow row={{ id, kind: "write", status: "live", label }} />
</ol>
);
}
function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
const KindIcon = KIND_ICONS[row.kind];
const expandable = Boolean(
(row.queries && row.queries.length > 0)
|| (row.sources && row.sources.length > 0)
|| row.thinkingText?.trim(),
);
const marker = (
<span
className={`agent-thinking-marker${row.status === "live" ? " is-live-marker" : ""}`}
aria-hidden="true"
>
{row.status === "live"
? <InlineSpinner size={12} />
: <Check />}
</span>
);
const label = (
<span className="consultation-run-timeline__label">
<KindIcon className="consultation-run-timeline__kind-icon" aria-hidden="true" />
<span
key={row.label}
className={row.status === "live" ? "agent-activity-status__text" : undefined}
role={row.status === "live" ? "status" : undefined}
>
{row.label}
</span>
</span>
);
if (!expandable) {
return (
<li
className={`agent-thinking-step consultation-run-timeline__row is-${row.status}`}
data-kind={row.kind}
data-status={row.status}
>
{marker}
{label}
</li>
);
}
return (
<li
className={`agent-thinking-step consultation-run-timeline__row is-${row.status}`}
data-kind={row.kind}
data-status={row.status}
>
<details className="consultation-run-timeline__details">
<summary>
{marker}
{label}
<ChevronDown className="consultation-run-timeline__chevron" aria-hidden="true" />
</summary>
<div className="consultation-run-timeline__body">
{row.queries && row.queries.length > 0 ? (
<ul className="consultation-run-timeline__queries">
{row.queries.map((query) => (
<li key={query}>{query}</li>
))}
</ul>
) : null}
{row.sources && row.sources.length > 0 ? (
<div className="consultation-run-timeline__sources">
{row.sources.map((source) => (
<span className="consultation-run-timeline__source" key={source}>{source}</span>
))}
</div>
) : null}
{row.thinkingText?.trim() ? (
<p className="consultation-run-timeline__thinking">{row.thinkingText}</p>
) : null}
</div>
</details>
</li>
);
}