feat(knowledge): folder-path breadcrumb above the space header

Inside a folder the header only showed the current folder's name — no
sense of depth, and getting back up meant repeated back-clicks. A
breadcrumb row (design 2075:8134) now sits above the header on desktop:
space name, then the ancestor chain, leaf last.

- Parent segments are clickable, capped at 8 CJK chars (96px @12px)
  with a full-name tooltip only when actually truncated.
- Chains deeper than 5 levels collapse the middle into an ellipsis
  whose hover card lists the hidden folders.
- Hidden at the space root so the single-level page keeps its layout;
  when shown, the leaf title drops to font-normal — the breadcrumb
  carries the hierarchy, the title no longer has to.

Suppressions: pruned entries the header edit and the latest merge made
stale (shrink-only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kinyoo
2026-08-04 15:17:18 +08:00
parent d64d63bed0
commit 424fcac799
3 changed files with 140 additions and 12 deletions
+2 -10
View File
@@ -741,11 +741,6 @@
"count": 1
}
},
"src/components/Chat/Messages/DeepThinkingGroup.tsx": {
"no-restricted-syntax": {
"count": 6
}
},
"src/components/Chat/Messages/HoverButtons.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -808,9 +803,6 @@
}
},
"src/components/Chat/Messages/ThinkingContent.tsx": {
"no-restricted-imports": {
"count": 1
},
"no-restricted-syntax": {
"count": 1
}
@@ -3345,7 +3337,7 @@
},
"src/pages/knowledge/SpaceDetail/KnowledgeSpaceHeader.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 5
"count": 4
}
},
"src/pages/knowledge/SpaceDetail/KnowledgeSpaceShareDialog.tsx": {
@@ -3436,7 +3428,7 @@
},
"src/pages/knowledge/index.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 3
"count": 2
},
"no-console": {
"count": 3
@@ -0,0 +1,120 @@
import { useState } from "react";
import { Outlined } from "bisheng-icons";
import { cn } from "~/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/Tooltip2";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "~/components/ui/HoverCard";
interface BreadcrumbNode {
id?: string;
name: string;
}
interface KnowledgeBreadcrumbProps {
spaceName: string;
/** Ancestor chain from root folder to the current folder (leaf last); space root NOT included. */
currentPath: BreadcrumbNode[];
onNavigateFolder: (folderId?: string) => void;
className?: string;
}
/** Parent segment width cap: 8 CJK characters at 12px (design "最大长度为8个中文字符"). */
const PARENT_MAX_WIDTH = "max-w-[96px]";
/** When the chain exceeds 5 levels, collapse the middle into an ellipsis (design 2075:8134). */
const COLLAPSE_THRESHOLD = 5;
function Separator() {
return <Outlined.Right className="size-4 shrink-0 text-text-4" aria-hidden />;
}
/** Clickable parent segment: hover highlights with the brand color; long names truncate with a tooltip. */
function ParentCrumb({ node, onNavigate }: { node: BreadcrumbNode; onNavigate: (folderId?: string) => void }) {
// The full-name tooltip only shows when the name actually overflows the width cap.
const [overflowing, setOverflowing] = useState(false);
const button = (
<button
type="button"
onClick={() => onNavigate(node.id)}
onMouseEnter={(e) => setOverflowing(e.currentTarget.scrollWidth > e.currentTarget.clientWidth)}
className={cn(
"shrink-0 truncate text-text-3 transition-colors hover:text-blue-600",
PARENT_MAX_WIDTH,
)}
>
{node.name}
</button>
);
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
{overflowing && (
<TooltipContent noArrow side="bottom" className="z-[999] bg-white px-3 py-2 text-sm text-[#4e5969] shadow-md">
{node.name}
</TooltipContent>
)}
</Tooltip>
);
}
/**
* Folder path breadcrumb above the knowledge-space header title (desktop only; design 2075:8134).
* Hidden at the space root ("single level" case) — the caller only renders it inside a folder.
*/
export function KnowledgeBreadcrumb({ spaceName, currentPath, onNavigateFolder, className }: KnowledgeBreadcrumbProps) {
// Full chain: space root first, current folder last.
const items: BreadcrumbNode[] = [{ id: undefined, name: spaceName }, ...currentPath];
const current = items[items.length - 1];
const collapse = items.length > COLLAPSE_THRESHOLD;
// Collapsed layout keeps the first item and the last two; the rest hide behind an ellipsis.
const headParents = collapse ? [items[0]] : items.slice(0, -1);
const hiddenParents = collapse ? items.slice(1, -2) : [];
const tailParents = collapse ? [items[items.length - 2]] : [];
return (
<nav
aria-label="breadcrumb"
className={cn("flex min-w-0 items-center gap-0.5 text-caption leading-6 text-text-3", className)}
>
{headParents.map((node) => (
<span key={node.id ?? "space-root"} className="flex shrink-0 items-center gap-0.5">
<ParentCrumb node={node} onNavigate={onNavigateFolder} />
<Separator />
</span>
))}
{collapse && (
<span className="flex shrink-0 items-center gap-0.5">
<HoverCard openDelay={100} closeDelay={150}>
<HoverCardTrigger asChild>
<span className="cursor-pointer px-0.5 text-text-3 transition-colors hover:text-blue-600">
...
</span>
</HoverCardTrigger>
<HoverCardContent
align="start"
className="flex w-auto max-w-[420px] flex-wrap items-center gap-x-0.5 gap-y-1 rounded-lg border border-border-base bg-white px-4 py-2 text-caption leading-6 text-text-3 shadow-md"
>
{hiddenParents.map((node, idx) => (
<span key={node.id} className="flex shrink-0 items-center gap-0.5">
{idx > 0 && <Separator />}
<ParentCrumb node={node} onNavigate={onNavigateFolder} />
</span>
))}
</HoverCardContent>
</HoverCard>
<Separator />
</span>
)}
{tailParents.map((node) => (
<span key={node.id ?? "space-root"} className="flex shrink-0 items-center gap-0.5">
<ParentCrumb node={node} onNavigate={onNavigateFolder} />
<Separator />
</span>
))}
{/* Current page: not clickable, no hover effect, only limited by the row width. */}
<span aria-current="page" className="min-w-0 truncate whitespace-nowrap">
{current.name}
</span>
</nav>
);
}
@@ -20,6 +20,7 @@ import {
import { Button } from "~/components/ui/Button";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/Tooltip2";
import { CopyShareLinkButton } from "~/components/CopyShareLinkButton";
import { KnowledgeBreadcrumb } from "./KnowledgeBreadcrumb";
import { useLocalize, useMediaQuery, usePrefersMobileLayout } from "~/hooks";
import { knowledgeUploadCapabilities } from "../knowledgeUploadCapabilities";
@@ -383,7 +384,21 @@ export function KnowledgeSpaceHeader({
);
return (
<div className="flex min-h-8 items-center justify-between gap-3 pt-4 pb-4 max-[767px]:gap-2 max-[767px]:pb-3">
<div className="flex flex-col">
{/* Folder-path breadcrumb (desktop only — this header never renders on mobile).
Hidden at the space root: with a single level the page content shifts up (design 2075:8134). */}
{currentPath.length > 0 && (
<KnowledgeBreadcrumb
className="pt-3"
spaceName={space.name}
currentPath={currentPath}
onNavigateFolder={onNavigateFolder}
/>
)}
<div className={cn(
"flex min-h-8 items-center justify-between gap-3 pb-4 max-[767px]:gap-2 max-[767px]:pb-3",
currentPath.length > 0 ? "pt-1" : "pt-4",
)}>
{/* 左侧:根目录显示空间标题 + 信息 + 分享;进入文件夹后显示返回按钮 + 分隔线 + 当前文件夹名(设计稿 11772:70584 */}
<div className="flex min-w-0 flex-1 items-center gap-1 text-sm">
@@ -403,7 +418,7 @@ export function KnowledgeSpaceHeader({
</button>
<div className="mx-1 h-4 w-px shrink-0 bg-[#e5e6eb]" aria-hidden />
*/}
<h1 className="min-w-0 truncate text-base font-medium text-[#1d2129] max-[767px]:text-[16px] max-[767px]:leading-6">
<h1 className="min-w-0 truncate text-base font-normal text-[#1d2129] max-[767px]:text-[16px] max-[767px]:leading-6">
{currentPath[currentPath.length - 1]?.name || space.name}
</h1>
</>
@@ -460,5 +475,6 @@ export function KnowledgeSpaceHeader({
{batchAndAddActions}
</div>
</div>
</div>
);
}