Studio: restructure Knowledge bases tab layout; hint under header, compact when empty

This commit is contained in:
Roland Tannous 2026-05-29 18:24:16 +04:00
commit 71b5214ca7
3 changed files with 107 additions and 89 deletions

View file

@ -25,6 +25,11 @@ vi.mock("@/features/rag/components/kb-list", () => ({
KBList: () => React.createElement("div", { "data-testid": "kb-list" }),
}));
vi.mock("@/features/rag/components/kb-create-dialog", () => ({
KBCreateDialog: () =>
React.createElement("div", { "data-testid": "kb-create-dialog" }),
}));
vi.mock("@/features/rag/components/kb-detail-panel", () => ({
KBDetailPanel: () =>
React.createElement("div", { "data-testid": "kb-detail-panel" }),

View file

@ -4,12 +4,10 @@
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Add01Icon, Delete02Icon } from "@hugeicons/core-free-icons";
import { Delete02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useState } from "react";
import type { KnowledgeBase } from "../api/rag-api";
import { useKnowledgeBases } from "../hooks/use-knowledge-bases";
import { KBCreateDialog } from "./kb-create-dialog";
export function KBList({
selectedId,
@ -19,25 +17,14 @@ export function KBList({
onSelect: (kb: KnowledgeBase | null) => void;
}) {
const { knowledgeBases, loading, error, deleteKB } = useKnowledgeBases();
const [createOpen, setCreateOpen] = useState(false);
return (
<div className="flex h-full flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Knowledge bases</h3>
<Button
variant="ghost"
size="icon"
aria-label="Create knowledge base"
onClick={() => setCreateOpen(true)}
>
<HugeiconsIcon icon={Add01Icon} size={16} />
</Button>
</div>
<div className="flex flex-col gap-1">
{error ? <div className="text-xs text-destructive">{error}</div> : null}
<ScrollArea className="flex-1">
{/* Capped, content-sized list: stays short when empty (no oddly-tall
box) and scrolls internally once there are many bases. */}
<ScrollArea className="max-h-[320px]">
<div className="flex flex-col gap-1 pr-2">
{knowledgeBases.length === 0 && !loading ? (
<div className="rounded-md border border-dashed border-border/60 px-3 py-6 text-center text-xs text-muted-foreground">
@ -108,12 +95,6 @@ export function KBList({
})}
</div>
</ScrollArea>
<KBCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={(kb) => onSelect(kb)}
/>
</div>
);
}

View file

@ -1,8 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { KnowledgeBase } from "@/features/rag/api/rag-api";
import { KBCreateDialog } from "@/features/rag/components/kb-create-dialog";
import { KBDetailPanel } from "@/features/rag/components/kb-detail-panel";
import { KBList } from "@/features/rag/components/kb-list";
import { PreviewPanel } from "@/features/rag/components/preview-panel";
@ -11,10 +13,17 @@ import { ThreadIndexList } from "@/features/rag/components/thread-index-list";
import { useResizablePanelWidth } from "@/features/rag/hooks/use-resizable-width";
import { usePreviewStore } from "@/features/rag/stores/preview-store";
import { cn } from "@/lib/utils";
import { Add01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type CSSProperties, useState } from "react";
// Height of the master-detail workspace shown once a knowledge base is
// selected. Only rendered then, so the section stays compact when empty.
const KB_WORKSPACE_HEIGHT = "h-[360px]";
export function KnowledgeBasesTab() {
const [selected, setSelected] = useState<KnowledgeBase | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const previewTarget = usePreviewStore((s) => s.target);
const previewStatus = usePreviewStore((s) => s.status);
const previewActive =
@ -46,76 +55,99 @@ export function KnowledgeBasesTab() {
</p>
</div>
<Separator />
{/* Fixed-height workspace so the KB list / detail / preview have a
bounded area to scroll within; the rest of the tab flows and the
settings dialog body scrolls. A flex-1 fill here collapsed under the
tall thread-index + defaults sections below, overlapping them. */}
<div className="flex h-[360px] min-h-0 gap-4">
<div className="w-[220px] shrink-0">
<KBList selectedId={selected?.id ?? null} onSelect={setSelected} />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Knowledge bases</h3>
<Button
variant="ghost"
size="icon"
aria-label="Create knowledge base"
onClick={() => setCreateOpen(true)}
>
<HugeiconsIcon icon={Add01Icon} size={16} />
</Button>
</div>
<Separator orientation="vertical" />
<div className="min-w-0 flex-1">
{selected ? (
<KBDetailPanel kb={selected} />
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a knowledge base, or create a new one to get started.
</div>
<p className="text-xs text-muted-foreground">
Select a knowledge base to manage its documents, or create a new one
to get started.
</p>
<div
className={cn(
"flex min-h-0 gap-4",
(selected || previewActive) && KB_WORKSPACE_HEIGHT,
)}
</div>
{previewActive ? (
<>
<button
type="button"
aria-label="Resize preview panel — drag, or use arrow keys"
aria-orientation="vertical"
onPointerDown={startPreviewResize}
onDoubleClick={resetPreviewWidth}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
adjustPreviewWidth(24);
} else if (event.key === "ArrowRight") {
event.preventDefault();
adjustPreviewWidth(-24);
} else if (event.key === "Home") {
event.preventDefault();
resetPreviewWidth();
}
}}
className={cn(
"group relative hidden w-1.5 cursor-col-resize touch-none select-none border-0 bg-transparent p-0 outline-none lg:block",
"before:absolute before:inset-y-0 before:left-1/2 before:-translate-x-1/2 before:w-px before:bg-border/70 before:transition-colors",
"hover:before:bg-primary/40 focus-visible:before:bg-primary/60",
previewResizing && "before:bg-primary/60",
)}
>
<span
aria-hidden="true"
>
<div className="w-[220px] shrink-0">
<KBList selectedId={selected?.id ?? null} onSelect={setSelected} />
</div>
{selected ? (
<>
<Separator orientation="vertical" className="h-auto" />
<div className="min-w-0 flex-1">
<KBDetailPanel kb={selected} />
</div>
</>
) : null}
{previewActive ? (
<>
<button
type="button"
aria-label="Resize preview panel — drag, or use arrow keys"
aria-orientation="vertical"
onPointerDown={startPreviewResize}
onDoubleClick={resetPreviewWidth}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
adjustPreviewWidth(24);
} else if (event.key === "ArrowRight") {
event.preventDefault();
adjustPreviewWidth(-24);
} else if (event.key === "Home") {
event.preventDefault();
resetPreviewWidth();
}
}}
className={cn(
"absolute left-1/2 top-1/2 h-10 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-border opacity-0 transition-opacity",
"group-hover:opacity-100 group-focus-visible:opacity-100",
previewResizing && "opacity-100",
"group relative hidden w-1.5 cursor-col-resize touch-none select-none border-0 bg-transparent p-0 outline-none lg:block",
"before:absolute before:inset-y-0 before:left-1/2 before:-translate-x-1/2 before:w-px before:bg-border/70 before:transition-colors",
"hover:before:bg-primary/40 focus-visible:before:bg-primary/60",
previewResizing && "before:bg-primary/60",
)}
/>
</button>
<div
className={cn(
"w-0 shrink-0 overflow-hidden max-lg:hidden lg:w-[var(--preview-w)]",
!previewResizing && "transition-[width] duration-200 ease-out",
)}
style={
{
"--preview-w": `${previewWidth}px`,
} as CSSProperties
}
>
<PreviewPanel open={previewActive} />
</div>
</>
) : null}
>
<span
aria-hidden="true"
className={cn(
"absolute left-1/2 top-1/2 h-10 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-border opacity-0 transition-opacity",
"group-hover:opacity-100 group-focus-visible:opacity-100",
previewResizing && "opacity-100",
)}
/>
</button>
<div
className={cn(
"w-0 shrink-0 overflow-hidden max-lg:hidden lg:w-[var(--preview-w)]",
!previewResizing && "transition-[width] duration-200 ease-out",
)}
style={
{
"--preview-w": `${previewWidth}px`,
} as CSSProperties
}
>
<PreviewPanel open={previewActive} />
</div>
</>
) : null}
</div>
<KBCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={(kb) => setSelected(kb)}
/>
</div>
<Separator />
<ThreadIndexList />
<Separator />