Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

295 lines
9.4 KiB
TypeScript

"use client";
import { Fragment, memo, useEffect, useRef, useState } from "react";
import {
BookOpen,
Bot,
ChevronRight,
Database,
Paperclip,
UserRound,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { SPACE_ITEMS } from "@/lib/space-items";
import { setPickerOrigin } from "@/lib/picker-origin";
type SelectableSpaceKey =
| "attach"
| "knowledge"
| "chat_history"
| "my_agents"
| "books"
| "notebooks"
| "question_bank"
| "persona"
| "memory";
export interface ChatSpaceSelectionCounts {
attachments: number;
knowledge: number;
chatHistory: number;
myAgents: number;
books: number;
notebooks: number;
questionBank: number;
persona: number;
memory: number;
}
interface ChatSpaceMenuProps {
variant: "toolbar" | "mention";
selectedCounts: ChatSpaceSelectionCounts;
/** Hide the Knowledge entry when no knowledge bases are configured. */
knowledgeAvailable?: boolean;
/**
* Hide the Persona entry. The main chat sets this to false — its
* persona lives in the standalone toolbar selector (and `/persona`),
* not in this menu. The quiz follow-up keeps the entry: this menu is
* its only persona entry point.
*/
personaAvailable?: boolean;
/** Hide the My Agents entry (e.g. the quiz follow-up surface). */
agentsAvailable?: boolean;
onSelectItem: (key: SelectableSpaceKey) => void;
}
const ITEM_ORDER: SelectableSpaceKey[] = [
"attach",
"knowledge",
"chat_history",
"my_agents",
"books",
"notebooks",
"question_bank",
"persona",
"memory",
];
function countFor(
key: SelectableSpaceKey,
counts: ChatSpaceSelectionCounts,
): number {
switch (key) {
case "attach":
return counts.attachments;
case "knowledge":
return counts.knowledge;
case "chat_history":
return counts.chatHistory;
case "my_agents":
return counts.myAgents;
case "books":
return counts.books;
case "notebooks":
return counts.notebooks;
case "question_bank":
return counts.questionBank;
case "persona":
return counts.persona;
case "memory":
return counts.memory;
default:
return 0;
}
}
export default memo(function ChatSpaceMenu({
variant,
selectedCounts,
knowledgeAvailable = true,
personaAvailable = true,
agentsAvailable = true,
onSelectItem,
}: ChatSpaceMenuProps) {
const { t } = useTranslation();
const compact = variant === "toolbar";
const isMention = variant === "mention";
// Render the items in a fixed, hand-tuned order so the menu always reads
// the same regardless of how SPACE_ITEMS may be reordered for navigation.
const items = ITEM_ORDER.filter((key) => {
if (key === "knowledge") return knowledgeAvailable;
if (key === "persona") return personaAvailable;
if (key === "my_agents") return agentsAvailable;
return true;
})
.map((key) => {
// The first two entries are composer-only concepts (not Space pages),
// so they are defined here rather than in SPACE_ITEMS.
if (key === "attach") {
return {
key,
label: "Attach files",
description: "Upload images, Office docs, code & text.",
icon: Paperclip,
};
}
if (key === "knowledge") {
return {
key,
label: "Knowledge",
description: "Search the selected knowledge bases.",
icon: Database,
};
}
if (key === "my_agents") {
return {
key,
label: "My Agents",
description: "Reference imported Claude Code / Codex conversations.",
icon: Bot,
};
}
if (key === "books") {
return {
key,
label: "Books",
description: "Reference generated book chapters in chat.",
icon: BookOpen,
};
}
if (key === "persona") {
return {
key,
label: "Persona",
description: "Apply a behavior persona for this turn.",
icon: UserRound,
};
}
return SPACE_ITEMS.find((it) => it.key === key)!;
})
.filter(Boolean);
// Active row index for keyboard navigation. Only meaningful in the
// mention variant — the toolbar variant is mouse/click driven.
const [activeIdx, setActiveIdx] = useState(0);
// The keydown handler closes over `activeIdx`/`items`/`onSelectItem`;
// stash them in refs so the document-level listener identity stays
// stable and we don't re-attach it on every render. Refs are synced
// in an effect (not during render) to satisfy `react-hooks/refs`.
const activeIdxRef = useRef(activeIdx);
const itemsRef = useRef(items);
const onSelectItemRef = useRef(onSelectItem);
useEffect(() => {
activeIdxRef.current = activeIdx;
}, [activeIdx]);
useEffect(() => {
itemsRef.current = items;
}, [items]);
useEffect(() => {
onSelectItemRef.current = onSelectItem;
}, [onSelectItem]);
// Reset to the top whenever the menu first mounts (i.e. user typed `@`
// and the popup appeared). The parent unmounts/remounts this component
// on each open, so a fresh `useState(0)` initial value already gives us
// the right behavior — no extra effect needed.
// Attach a document-level keydown so Arrow/Enter while the textarea
// still has focus drive the menu. The textarea's own handleKeyDown
// continues to handle Escape and submit.
useEffect(() => {
if (!isMention) return;
const handler = (e: KeyboardEvent) => {
const list = itemsRef.current;
if (list.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx((i) => (i + 1) % list.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx((i) => (i - 1 + list.length) % list.length);
} else if (e.key === "Enter") {
const item = list[activeIdxRef.current];
if (!item) return;
e.preventDefault();
onSelectItemRef.current(item.key as SelectableSpaceKey);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isMention]);
return (
<div
role={isMention ? "listbox" : undefined}
aria-label={isMention ? t("Reference space") : undefined}
className={`rounded-xl border border-[var(--border)] bg-[var(--popover)] shadow-lg backdrop-blur-md ${
compact ? "w-[280px] py-1.5" : "w-64 p-2"
}`}
>
<div className={compact ? "" : "space-y-1"}>
{items.map(({ key, label, description, icon: Icon }, idx) => {
const count = countFor(key as SelectableSpaceKey, selectedCounts);
const isActive = isMention && idx === activeIdx;
// Two kinds of rows, Claude-style: "attach" is a direct action
// (opens the file dialog), everything after it opens a
// second-level picker — those get a trailing chevron, and a
// divider separates the two groups.
const opensPicker = key !== "attach";
return (
<Fragment key={key}>
{idx === 1 && items[0]?.key === "attach" && (
<div
className={`border-t border-[var(--border)]/60 ${
compact ? "mx-3 my-1" : "mx-1 my-1"
}`}
/>
)}
<button
type="button"
role={isMention ? "option" : undefined}
aria-selected={isMention ? isActive : undefined}
onMouseEnter={isMention ? () => setActiveIdx(idx) : undefined}
onClick={(e) => {
// Record the row's rect so the fullscreen picker can expand
// outward from exactly this clickable box.
setPickerOrigin(e.currentTarget.getBoundingClientRect());
onSelectItem(key as SelectableSpaceKey);
}}
className={`flex w-full items-center gap-2.5 text-left transition-colors active:bg-[var(--muted)]/70 ${
isActive
? "bg-[var(--muted)]/60"
: "hover:bg-[var(--muted)]/40"
} ${
compact
? "px-3.5 py-2 text-[13px]"
: "rounded-xl px-3 py-2.5 text-[13px]"
}`}
>
<Icon
size={15}
strokeWidth={1.7}
className="shrink-0 text-[var(--muted-foreground)]"
/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-[var(--foreground)]">
{t(label)}
</span>
{!compact && (
<span className="mt-0.5 block truncate text-[11px] text-[var(--muted-foreground)]">
{t(description)}
</span>
)}
</span>
{count > 0 && (
<span className="shrink-0 rounded-full bg-[var(--primary)]/10 px-1.5 py-px text-[9px] font-semibold text-[var(--primary)]">
{count}
</span>
)}
{opensPicker && (
<ChevronRight
size={14}
strokeWidth={1.8}
className="shrink-0 text-[var(--muted-foreground)]/55"
/>
)}
</button>
</Fragment>
);
})}
</div>
</div>
);
});