317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import type { Message } from "@langchain/langgraph-sdk";
|
||
import { ChevronsRightIcon, ListIcon } from "lucide-react";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
} from "@/components/ui/sheet";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
import { buildQuestionItems, type QuestionItem } from "./question-navigator-data";
|
||
import { Tooltip } from "./tooltip";
|
||
|
||
export { getQuestionAnchorId } from "./question-navigator-data";
|
||
|
||
/** Width of the expanded floating panel, in px. */
|
||
const PANEL_WIDTH = 300;
|
||
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
|
||
const PANEL_GUTTER_MARGIN = 24;
|
||
/** Questions needed before the navigator shows up at all. */
|
||
const MIN_QUESTIONS = 2;
|
||
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
|
||
|
||
/** Walk up from a message anchor to the element that actually scrolls. */
|
||
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
|
||
let node = el?.parentElement ?? null;
|
||
while (node) {
|
||
const overflowY = window.getComputedStyle(node).overflowY;
|
||
if (
|
||
(overflowY === "auto" || overflowY === "scroll") &&
|
||
node.scrollHeight > node.clientHeight
|
||
) {
|
||
return node;
|
||
}
|
||
node = node.parentElement;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function readCollapsedPreference(): boolean {
|
||
if (typeof window === "undefined") return false;
|
||
try {
|
||
return window.localStorage.getItem(COLLAPSE_STORAGE_KEY) === "1";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ChatGPT-style question index floating at the right edge of the conversation
|
||
* column. Reads questions straight from the live `messages` array (no global
|
||
* state copy), so it stays in sync with streaming, history switches and new
|
||
* turns for free.
|
||
*
|
||
* Layout contract: render inside a `relative` ancestor that spans the chat
|
||
* content area, and pass the inset via `className` (e.g. `top-16 bottom-48
|
||
* left-0 right-0`). The component measures its own width: when the side gutter
|
||
* next to the centered message column is wide enough it floats an expandable
|
||
* panel there, vertically centered in the available chat area; on narrower
|
||
* viewports it degrades to a small pill button that opens a right-side drawer,
|
||
* so it never covers the chat body or input box.
|
||
*/
|
||
export function QuestionNavigator({
|
||
messages,
|
||
className,
|
||
contentMaxWidth = 1024,
|
||
}: {
|
||
messages: Message[];
|
||
className?: string;
|
||
/** Max width (px) of the centered message column, used to size the gutter. */
|
||
contentMaxWidth?: number;
|
||
}) {
|
||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||
const [panelFits, setPanelFits] = useState(false);
|
||
const [collapsed, setCollapsed] = useState(readCollapsedPreference);
|
||
const [sheetOpen, setSheetOpen] = useState(false);
|
||
const [activeId, setActiveId] = useState<string | null>(null);
|
||
|
||
const questions = useMemo(() => buildQuestionItems(messages), [messages]);
|
||
|
||
const visible = questions.length >= MIN_QUESTIONS;
|
||
|
||
// Decide between floating panel and drawer mode from the actual gutter width
|
||
// next to the centered message column — not from the browser viewport — so
|
||
// opening the artifact preview or the sidebar reflows the mode correctly.
|
||
useEffect(() => {
|
||
if (!visible) return;
|
||
const root = rootRef.current;
|
||
if (!root) return;
|
||
const update = () => {
|
||
const gutter = (root.clientWidth - contentMaxWidth) / 2;
|
||
setPanelFits(gutter >= PANEL_WIDTH + PANEL_GUTTER_MARGIN);
|
||
};
|
||
update();
|
||
const observer = new ResizeObserver(update);
|
||
observer.observe(root);
|
||
return () => observer.disconnect();
|
||
}, [visible, contentMaxWidth]);
|
||
|
||
// Highlight the question nearest the top of the chat viewport. A plain
|
||
// rAF-throttled scroll listener over a handful of anchors is cheaper and
|
||
// more predictable here than juggling IntersectionObserver thresholds.
|
||
useEffect(() => {
|
||
if (!visible) {
|
||
setActiveId(null);
|
||
return;
|
||
}
|
||
const firstAnchor = document.getElementById(questions[0]!.anchorId);
|
||
const scroller = findScrollParent(firstAnchor);
|
||
if (!scroller) return;
|
||
|
||
let raf = 0;
|
||
const update = () => {
|
||
raf = 0;
|
||
const viewportTop = scroller.getBoundingClientRect().top;
|
||
let current = questions[0]!.anchorId;
|
||
for (const question of questions) {
|
||
const el = document.getElementById(question.anchorId);
|
||
if (!el) continue;
|
||
if (el.getBoundingClientRect().top - viewportTop <= 140) {
|
||
current = question.anchorId;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
setActiveId(current);
|
||
};
|
||
const onScroll = () => {
|
||
if (!raf) raf = requestAnimationFrame(update);
|
||
};
|
||
update();
|
||
scroller.addEventListener("scroll", onScroll, { passive: true });
|
||
return () => {
|
||
scroller.removeEventListener("scroll", onScroll);
|
||
if (raf) cancelAnimationFrame(raf);
|
||
};
|
||
}, [visible, questions]);
|
||
|
||
const scrollToQuestion = useCallback((anchorId: string) => {
|
||
document
|
||
.getElementById(anchorId)
|
||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
setActiveId(anchorId);
|
||
}, []);
|
||
|
||
const toggleCollapsed = useCallback(() => {
|
||
setCollapsed((prev) => {
|
||
const next = !prev;
|
||
try {
|
||
window.localStorage.setItem(COLLAPSE_STORAGE_KEY, next ? "1" : "0");
|
||
} catch {
|
||
// localStorage unavailable (private mode) — keep in-memory state only.
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
if (!visible) return null;
|
||
|
||
const pillButton = (label: string, onClick: () => void) => (
|
||
<Tooltip content={label}>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="bg-background/80 pointer-events-auto h-8 gap-1.5 rounded-full px-3 shadow-md backdrop-blur-md"
|
||
onClick={onClick}
|
||
>
|
||
<ListIcon className="size-3.5" />
|
||
<span className="text-xs">问题列表</span>
|
||
<span className="text-muted-foreground text-xs tabular-nums">
|
||
{questions.length}
|
||
</span>
|
||
</Button>
|
||
</Tooltip>
|
||
);
|
||
|
||
return (
|
||
<div
|
||
ref={rootRef}
|
||
className={cn("pointer-events-none absolute z-20", className)}
|
||
>
|
||
<div className="absolute inset-y-0 right-3 flex items-center justify-end">
|
||
{panelFits ? (
|
||
collapsed ? (
|
||
pillButton("展开问题导航", toggleCollapsed)
|
||
) : (
|
||
<div
|
||
className="border-border/60 bg-background/85 pointer-events-auto flex max-h-full flex-col overflow-hidden rounded-xl border shadow-lg backdrop-blur-md"
|
||
style={{ width: PANEL_WIDTH }}
|
||
>
|
||
<div className="border-border/50 flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
||
<Tooltip content="收起问题导航">
|
||
<Button
|
||
type="button"
|
||
size="icon-sm"
|
||
variant="ghost"
|
||
className="size-6"
|
||
onClick={toggleCollapsed}
|
||
>
|
||
<ChevronsRightIcon className="size-3.5" />
|
||
</Button>
|
||
</Tooltip>
|
||
<span className="text-xs font-medium">本轮问题</span>
|
||
<span className="text-muted-foreground ml-auto text-xs tabular-nums">
|
||
{questions.length}
|
||
</span>
|
||
</div>
|
||
<QuestionCardList
|
||
questions={questions}
|
||
activeId={activeId}
|
||
onSelect={scrollToQuestion}
|
||
className="min-h-0 flex-1"
|
||
/>
|
||
</div>
|
||
)
|
||
) : (
|
||
<>
|
||
{pillButton("打开问题导航", () => setSheetOpen(true))}
|
||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||
<SheetContent
|
||
side="right"
|
||
className="flex w-[320px] flex-col gap-0 p-0 sm:max-w-[320px]"
|
||
>
|
||
<SheetHeader className="border-border/50 border-b px-4 py-3">
|
||
<SheetTitle className="text-sm">
|
||
本轮问题({questions.length})
|
||
</SheetTitle>
|
||
</SheetHeader>
|
||
<QuestionCardList
|
||
questions={questions}
|
||
activeId={activeId}
|
||
onSelect={(anchorId) => {
|
||
setSheetOpen(false);
|
||
scrollToQuestion(anchorId);
|
||
}}
|
||
className="min-h-0 flex-1"
|
||
/>
|
||
</SheetContent>
|
||
</Sheet>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function QuestionCardList({
|
||
questions,
|
||
activeId,
|
||
onSelect,
|
||
className,
|
||
}: {
|
||
questions: QuestionItem[];
|
||
activeId: string | null;
|
||
onSelect: (anchorId: string) => void;
|
||
className?: string;
|
||
}) {
|
||
const itemRefs = useRef(new Map<string, HTMLButtonElement>());
|
||
|
||
// Keep the active card visible as the chat scrolls. `block: "nearest"` only
|
||
// nudges this inner list — the card is already inside the on-screen panel,
|
||
// so no outer container moves.
|
||
useEffect(() => {
|
||
if (!activeId) return;
|
||
itemRefs.current.get(activeId)?.scrollIntoView({ block: "nearest" });
|
||
}, [activeId]);
|
||
|
||
return (
|
||
<nav
|
||
aria-label="问题导航"
|
||
className={cn("flex flex-col gap-2 overflow-y-auto p-2", className)}
|
||
>
|
||
{questions.map((question, index) => {
|
||
const isActive = question.anchorId === activeId;
|
||
return (
|
||
<button
|
||
key={question.anchorId}
|
||
ref={(el) => {
|
||
if (el) itemRefs.current.set(question.anchorId, el);
|
||
else itemRefs.current.delete(question.anchorId);
|
||
}}
|
||
type="button"
|
||
title={question.summary}
|
||
onClick={() => onSelect(question.anchorId)}
|
||
className={cn(
|
||
"w-full cursor-pointer rounded-lg border px-3 py-2 text-left text-[13px] leading-5 shadow-xs transition-colors",
|
||
isActive
|
||
? "border-primary/40 border-l-primary bg-primary/10 text-foreground border-l-2 font-medium"
|
||
: "border-border/50 bg-background/60 text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||
)}
|
||
>
|
||
<span className="flex items-start gap-1.5">
|
||
<span
|
||
className={cn(
|
||
"shrink-0 text-[11px] leading-5 tabular-nums",
|
||
isActive ? "text-primary" : "text-muted-foreground/60",
|
||
)}
|
||
>
|
||
{index + 1}.
|
||
</span>
|
||
<span className="line-clamp-2 break-words">
|
||
{question.summary}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
);
|
||
}
|