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
497 lines
18 KiB
TypeScript
497 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import { useEffect, useState, type ReactNode } from "react";
|
|
import { useAppShell } from "@/context/AppShellContext";
|
|
import {
|
|
BookOpen,
|
|
BookText,
|
|
Bot,
|
|
Brain,
|
|
ChevronDown,
|
|
Github,
|
|
HeartHandshake,
|
|
House,
|
|
LayoutGrid,
|
|
Library,
|
|
Lock,
|
|
PanelLeftClose,
|
|
PanelLeftOpen,
|
|
PenLine,
|
|
Settings,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import SessionList from "@/components/SessionList";
|
|
import { VersionBadge } from "@/components/sidebar/VersionBadge";
|
|
import type { SessionSummary } from "@/lib/session-api";
|
|
import { Tooltip } from "@/components/ui/Tooltip";
|
|
import { useCapabilityAccess } from "@/components/access/CapabilityAccessContext";
|
|
import type { Capability } from "@/lib/capability-routes";
|
|
|
|
interface NavEntry {
|
|
href: string;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
tooltipKey?: string;
|
|
/** Model capability this feature needs; locked when the user lacks it. */
|
|
requires?: Capability;
|
|
}
|
|
|
|
const PRIMARY_NAV: NavEntry[] = [
|
|
{
|
|
href: "/home",
|
|
label: "Home",
|
|
icon: House,
|
|
tooltipKey: "Home tooltip",
|
|
requires: "llm",
|
|
},
|
|
{
|
|
href: "/partners",
|
|
label: "Partners",
|
|
icon: HeartHandshake,
|
|
tooltipKey: "Partners tooltip",
|
|
requires: "llm",
|
|
},
|
|
{
|
|
// My Agents is its own top-level feature (pulled out of the Learning
|
|
// Space): connect a live local Claude Code / Codex to consult in chat,
|
|
// and manage imported agent conversations. Ungated — managing connections
|
|
// and imports needs no per-user model grant.
|
|
href: "/agents",
|
|
label: "My Agents",
|
|
icon: Bot,
|
|
tooltipKey: "Agents tooltip",
|
|
},
|
|
{
|
|
href: "/co-writer",
|
|
label: "Co-Writer",
|
|
icon: PenLine,
|
|
tooltipKey: "Co-Writer tooltip",
|
|
requires: "llm",
|
|
},
|
|
{
|
|
href: "/book",
|
|
label: "Book",
|
|
icon: Library,
|
|
tooltipKey: "Book tooltip",
|
|
requires: "llm",
|
|
},
|
|
{
|
|
href: "/space",
|
|
label: "Learning Space",
|
|
icon: LayoutGrid,
|
|
tooltipKey: "Space tooltip",
|
|
},
|
|
];
|
|
|
|
const SECONDARY_NAV: NavEntry[] = [
|
|
{
|
|
// Memory is its own top-level console (pulled out of the Learning Space):
|
|
// a place to inspect and curate the tutor's long-term memory, not a daily
|
|
// workspace. Never gated — memory has no per-user model requirement.
|
|
href: "/memory",
|
|
label: "Memory",
|
|
icon: Brain,
|
|
tooltipKey: "Memory tooltip",
|
|
},
|
|
{
|
|
// Knowledge Center sits just above Settings: it's a console for managing
|
|
// KBs and retrieval engines, not a daily workspace. Never gated — embedding
|
|
// / search are shared admin infrastructure, no per-user model grant needed.
|
|
href: "/knowledge",
|
|
label: "Knowledge Center",
|
|
icon: BookOpen,
|
|
tooltipKey: "Knowledge tooltip",
|
|
},
|
|
{ href: "/settings", label: "Settings", icon: Settings },
|
|
];
|
|
const GITHUB_REPO_URL = "https://github.com/HKUDS/DeepTutor";
|
|
const DOCS_URL = "https://deeptutor.info/";
|
|
const RECENTS_COLLAPSED_KEY = "deeptutor.sidebar.recentsCollapsed";
|
|
|
|
interface SidebarShellProps {
|
|
sessions?: SessionSummary[];
|
|
activeSessionId?: string | null;
|
|
loadingSessions?: boolean;
|
|
showSessions?: boolean;
|
|
/** Clicking the Chat nav item resets to a fresh session via this handler. */
|
|
onNewChat?: () => void;
|
|
onSelectSession?: (sessionId: string) => void | Promise<void>;
|
|
onRenameSession?: (sessionId: string, title: string) => void | Promise<void>;
|
|
onDeleteSession?: (sessionId: string) => void | Promise<void>;
|
|
/**
|
|
* Footer content rendered below the nav. Pass a render function to receive
|
|
* the current ``collapsed`` state so footer items (e.g. Admin / Sign out) can
|
|
* switch to their icon-only variant when the rail is collapsed.
|
|
*/
|
|
footerSlot?: ReactNode | ((collapsed: boolean) => ReactNode);
|
|
}
|
|
|
|
export function SidebarShell({
|
|
sessions = [],
|
|
activeSessionId = null,
|
|
loadingSessions = false,
|
|
showSessions = false,
|
|
onNewChat,
|
|
onSelectSession,
|
|
onRenameSession,
|
|
onDeleteSession,
|
|
footerSlot,
|
|
}: SidebarShellProps) {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const { t } = useTranslation();
|
|
const { has } = useCapabilityAccess();
|
|
const { sidebarCollapsed: collapsed, setSidebarCollapsed: setCollapsed } =
|
|
useAppShell();
|
|
|
|
const navLocked = (item: NavEntry) =>
|
|
item.requires ? !has(item.requires) : false;
|
|
const lockedTooltip = t("Locked — contact your administrator to get access.");
|
|
const renderedFooter =
|
|
typeof footerSlot === "function" ? footerSlot(collapsed) : footerSlot;
|
|
const [recentsCollapsed, setRecentsCollapsed] = useState(false);
|
|
|
|
// Hydrate Recents collapse from localStorage after first render to stay SSR-safe.
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setRecentsCollapsed(
|
|
window.localStorage.getItem(RECENTS_COLLAPSED_KEY) === "1",
|
|
);
|
|
}, []);
|
|
|
|
const toggleRecents = () => {
|
|
setRecentsCollapsed((prev) => {
|
|
const next = !prev;
|
|
if (typeof window !== "undefined") {
|
|
window.localStorage.setItem(RECENTS_COLLAPSED_KEY, next ? "1" : "0");
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleHomeClick = (event: React.MouseEvent) => {
|
|
// Always reset to a fresh session (mirrors the old "New Chat" affordance);
|
|
// let modifier-clicks fall through to default Link behavior so middle-click
|
|
// open-in-new-tab still works.
|
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1)
|
|
return;
|
|
event.preventDefault();
|
|
onNewChat?.();
|
|
router.push("/home");
|
|
};
|
|
|
|
/* ---- Collapsed state ---- */
|
|
if (collapsed) {
|
|
return (
|
|
<aside className="group/sb relative flex h-screen w-[60px] shrink-0 flex-col items-center bg-[var(--secondary)] py-3 transition-all duration-200">
|
|
{/* Header: logo + collapse toggle (toggle replaces logo on hover) */}
|
|
<div className="relative mb-2 flex h-9 w-9 items-center justify-center">
|
|
<Link
|
|
href="/"
|
|
aria-label="DeepTutor"
|
|
className="flex items-center justify-center transition-opacity duration-150 group-hover/sb:opacity-0"
|
|
>
|
|
<Image
|
|
src="/logo.png"
|
|
alt="DeepTutor"
|
|
width={22}
|
|
height={22}
|
|
className="h-[22px] w-[22px] rounded-md"
|
|
/>
|
|
</Link>
|
|
<button
|
|
onClick={() => setCollapsed(false)}
|
|
className="absolute inset-0 flex items-center justify-center rounded-lg text-[var(--muted-foreground)] opacity-0 transition-all duration-150 hover:bg-[var(--background)]/60 hover:text-[var(--foreground)] group-hover/sb:opacity-100"
|
|
aria-label={t("Expand sidebar")}
|
|
>
|
|
<PanelLeftOpen size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Primary nav */}
|
|
<nav className="mt-1 flex w-full flex-col items-center gap-1 px-1.5">
|
|
{PRIMARY_NAV.map((item) => {
|
|
const active = pathname.startsWith(item.href);
|
|
const locked = navLocked(item);
|
|
const description = locked
|
|
? lockedTooltip
|
|
: item.tooltipKey
|
|
? t(item.tooltipKey)
|
|
: undefined;
|
|
if (locked) {
|
|
return (
|
|
<Tooltip
|
|
key={item.href}
|
|
label={t(item.label)}
|
|
description={description}
|
|
side="right"
|
|
>
|
|
<div
|
|
aria-label={`${t(item.label)} — ${lockedTooltip}`}
|
|
aria-disabled
|
|
className="relative flex h-9 w-9 cursor-not-allowed items-center justify-center rounded-xl text-[var(--muted-foreground)]/40"
|
|
>
|
|
<item.icon size={18} strokeWidth={1.6} />
|
|
<Lock
|
|
size={10}
|
|
strokeWidth={2}
|
|
className="absolute bottom-1 right-1 text-[var(--muted-foreground)]/70"
|
|
/>
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
return (
|
|
<Tooltip
|
|
key={item.href}
|
|
label={t(item.label)}
|
|
description={description}
|
|
side="right"
|
|
>
|
|
<Link
|
|
href={item.href}
|
|
onClick={item.href === "/home" ? handleHomeClick : undefined}
|
|
aria-label={t(item.label)}
|
|
className={`relative flex h-9 w-9 items-center justify-center rounded-xl transition-all duration-150 ${
|
|
active
|
|
? "bg-[var(--accent)] text-[var(--foreground)] shadow-sm"
|
|
: "text-[var(--foreground)]/85 hover:bg-[var(--background)]/60 hover:text-[var(--foreground)]"
|
|
}`}
|
|
>
|
|
<item.icon size={18} strokeWidth={active ? 2 : 1.6} />
|
|
</Link>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<div className="flex-1" />
|
|
|
|
{/* Secondary nav + footer */}
|
|
<div className="flex w-full flex-col items-center gap-1 px-1.5">
|
|
<div className="my-1 h-px w-7 bg-[var(--border)]/40" />
|
|
{SECONDARY_NAV.map((item) => {
|
|
const active = pathname.startsWith(item.href);
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
title={t(item.label) as string}
|
|
className={`relative flex h-9 w-9 items-center justify-center rounded-xl transition-all duration-150 ${
|
|
active
|
|
? "bg-[var(--accent)] text-[var(--foreground)] shadow-sm"
|
|
: "text-[var(--foreground)]/85 hover:bg-[var(--background)]/60 hover:text-[var(--foreground)]"
|
|
}`}
|
|
>
|
|
<item.icon size={18} strokeWidth={active ? 2 : 1.6} />
|
|
</Link>
|
|
);
|
|
})}
|
|
{renderedFooter}
|
|
<a
|
|
href={DOCS_URL}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
title={t("Docs") as string}
|
|
aria-label={t("Docs") as string}
|
|
className="mt-1 flex h-9 w-9 items-center justify-center rounded-xl text-[var(--muted-foreground)]/70 transition-colors hover:bg-[var(--background)]/50 hover:text-[var(--foreground)]"
|
|
>
|
|
<BookText size={15} strokeWidth={1.6} />
|
|
</a>
|
|
<a
|
|
href={GITHUB_REPO_URL}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
title="GitHub"
|
|
aria-label="GitHub"
|
|
className="flex h-9 w-9 items-center justify-center rounded-xl text-[var(--muted-foreground)]/70 transition-colors hover:bg-[var(--background)]/50 hover:text-[var(--foreground)]"
|
|
>
|
|
<Github size={15} strokeWidth={1.6} />
|
|
</a>
|
|
<VersionBadge collapsed />
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
/* ---- Expanded state ---- */
|
|
return (
|
|
<aside className="flex w-[220px] h-screen shrink-0 flex-col bg-[var(--secondary)] transition-all duration-200">
|
|
{/* Header: logo + collapse toggle */}
|
|
<div className="flex h-14 items-center justify-between px-4">
|
|
<Link href="/" className="group flex items-center gap-1.5">
|
|
<Image
|
|
src="/logo.png"
|
|
alt="DeepTutor"
|
|
width={22}
|
|
height={22}
|
|
className="h-[22px] w-[22px] transition-transform duration-200 group-hover:scale-105"
|
|
/>
|
|
<Image
|
|
src="/banner.png"
|
|
alt="DeepTutor"
|
|
width={897}
|
|
height={236}
|
|
priority
|
|
className="h-[22px] w-auto transition-transform duration-200 group-hover:scale-105"
|
|
/>
|
|
</Link>
|
|
<button
|
|
onClick={() => setCollapsed(true)}
|
|
className="rounded-md p-1 text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
|
|
aria-label={t("Collapse sidebar")}
|
|
>
|
|
<PanelLeftClose size={15} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Primary nav */}
|
|
<nav className="px-2 pt-1">
|
|
<div className="space-y-px">
|
|
{PRIMARY_NAV.map((item) => {
|
|
const active = pathname.startsWith(item.href);
|
|
const locked = navLocked(item);
|
|
if (locked) {
|
|
return (
|
|
<Tooltip
|
|
key={item.href}
|
|
label={t(item.label)}
|
|
description={lockedTooltip}
|
|
side="right"
|
|
>
|
|
<div
|
|
aria-label={`${t(item.label)} — ${lockedTooltip}`}
|
|
aria-disabled
|
|
className="flex cursor-not-allowed items-center gap-2.5 rounded-lg px-3 py-2 text-[13.5px] text-[var(--muted-foreground)]/40"
|
|
>
|
|
<item.icon size={16} strokeWidth={1.5} />
|
|
<span>{t(item.label)}</span>
|
|
<Lock size={13} strokeWidth={1.8} className="ml-auto" />
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
onClick={item.href === "/home" ? handleHomeClick : undefined}
|
|
className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13.5px] transition-colors ${
|
|
active
|
|
? "bg-[var(--accent)] font-medium text-[var(--foreground)]"
|
|
: "text-[var(--foreground)]/85 hover:bg-[var(--background)]/60 hover:text-[var(--foreground)]"
|
|
}`}
|
|
>
|
|
<item.icon size={16} strokeWidth={active ? 1.9 : 1.5} />
|
|
<span>{t(item.label)}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Chat history — its own region below the nav, takes remaining height */}
|
|
{showSessions && onSelectSession && onRenameSession && onDeleteSession ? (
|
|
<section
|
|
className={`mt-4 flex min-h-0 flex-col ${
|
|
recentsCollapsed ? "" : "flex-1"
|
|
}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={toggleRecents}
|
|
className="group/recents mx-2 flex items-center justify-between rounded-md px-2 py-1 text-left text-[11.5px] font-normal text-[var(--muted-foreground)]/60 transition-colors hover:bg-[var(--background)]/40 hover:text-[var(--muted-foreground)]"
|
|
aria-expanded={!recentsCollapsed}
|
|
aria-label={
|
|
recentsCollapsed
|
|
? (t("Show recents") as string)
|
|
: (t("Hide recents") as string)
|
|
}
|
|
>
|
|
<span>{t("Recents")}</span>
|
|
<ChevronDown
|
|
size={13}
|
|
strokeWidth={1.7}
|
|
className={`transition-all duration-200 ${
|
|
recentsCollapsed
|
|
? "-rotate-90 opacity-60"
|
|
: "rotate-0 opacity-0 group-hover/recents:opacity-60"
|
|
}`}
|
|
/>
|
|
</button>
|
|
{!recentsCollapsed && (
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-0.5">
|
|
<SessionList
|
|
sessions={sessions}
|
|
activeSessionId={activeSessionId}
|
|
loading={loadingSessions}
|
|
onSelect={onSelectSession}
|
|
onRename={onRenameSession}
|
|
onDelete={onDeleteSession}
|
|
compact
|
|
/>
|
|
</div>
|
|
)}
|
|
</section>
|
|
) : null}
|
|
|
|
{/* When recents is collapsed or unavailable, fill the gap above the footer. */}
|
|
{(!showSessions ||
|
|
!onSelectSession ||
|
|
!onRenameSession ||
|
|
!onDeleteSession ||
|
|
recentsCollapsed) && <div className="flex-1" />}
|
|
|
|
{/* Secondary nav + footer */}
|
|
<div className="border-t border-[var(--border)]/40 px-2 py-2">
|
|
{SECONDARY_NAV.map((item) => {
|
|
const active = pathname.startsWith(item.href);
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13.5px] transition-colors ${
|
|
active
|
|
? "bg-[var(--accent)] font-medium text-[var(--foreground)]"
|
|
: "text-[var(--foreground)]/85 hover:bg-[var(--background)]/60 hover:text-[var(--foreground)]"
|
|
}`}
|
|
>
|
|
<item.icon size={16} strokeWidth={active ? 1.9 : 1.5} />
|
|
<span>{t(item.label)}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
{renderedFooter}
|
|
<div className="mt-0.5 flex items-center gap-0.5">
|
|
<VersionBadge />
|
|
<a
|
|
href={DOCS_URL}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
title={t("Docs") as string}
|
|
aria-label={t("Docs") as string}
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[var(--muted-foreground)]/55 transition-colors hover:bg-[var(--background)]/50 hover:text-[var(--muted-foreground)]"
|
|
>
|
|
<BookText size={13} strokeWidth={1.7} />
|
|
</a>
|
|
<a
|
|
href={GITHUB_REPO_URL}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
title="GitHub"
|
|
aria-label="GitHub"
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[var(--muted-foreground)]/55 transition-colors hover:bg-[var(--background)]/50 hover:text-[var(--muted-foreground)]"
|
|
>
|
|
<Github size={13} strokeWidth={1.7} />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|