* new-chat shortcut, composer draft autosave, archive threads * fix * Studio: harden chat UX additions for legacy threads and unavailable storage Two robustness fixes on top of the new chat UX features: - groupThreads: coerce archived to a boolean before comparing (Boolean(t.archived) !== archived). Threads from the older browser-only Studio, or any record predating the archived field, can carry archived === undefined/null. The raw `!== archived` comparison dropped those from BOTH the Recents and Archived sidebar groups, hiding existing chats. Treat missing as not-archived so legacy chats still appear in Recents. - composer draft autosave: wrap the localStorage read and write in try/catch. When storage is unavailable (private mode, disabled cookies, blocked storage) or full (quota exceeded), getItem/setItem throw; the throw in the restore effect would surface to React and break the chat page. Draft persistence is best-effort, so degrade quietly. Verified with bun unit tests on the real groupThreads (legacy undefined no longer vanishes) and Playwright across chromium, firefox and webkit (draft save/restore/isolation/clear, new-chat shortcut + crypto.randomUUID, and localStorage blocked/quota throw handling). tsc clean; no new eslint findings. * Fix composer draft bleed and orphan cleanup for PR #5771 Centralize composer draft storage in a small util and tighten two edge cases: - New chat draft bleed: every new chat shared the chat-draft:__new__ slot, so starting a fresh chat could restore the previous one's half-typed text. Clear that slot at every new chat entry point (sidebar buttons and Cmd/Ctrl+Shift+O). - Orphan drafts: deleting a thread left its chat-draft:<id> key behind. Clear the draft for every deleted thread id. New util utils/composer-draft.ts owns the key format and wraps localStorage in try/catch (private mode, blocked storage, quota), replacing the inline copy in thread.tsx so reads and writes stay best effort everywhere. * address review * fix: remove unused chat sidebar binding --------- Co-authored-by: Daniel Han <michaelhan2050@gmail.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: imagineer99 <samleejackson0@gmail.com>
1501 lines
59 KiB
TypeScript
1501 lines
59 KiB
TypeScript
// 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 {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
useSidebar,
|
|
} from "@/components/ui/sidebar";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuShortcut,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
Archive01Icon,
|
|
ArchiveRestoreIcon,
|
|
ChefHatIcon,
|
|
CursorInfo02Icon,
|
|
DashboardCircleIcon,
|
|
Delete02Icon,
|
|
Download01Icon,
|
|
DownloadSquare01Icon,
|
|
Edit03Icon,
|
|
FolderAddIcon,
|
|
FolderExportIcon,
|
|
Folder01Icon,
|
|
Globe02Icon,
|
|
HelpCircleIcon,
|
|
Logout05Icon,
|
|
MoreVerticalIcon,
|
|
Search01Icon,
|
|
PlusSignIcon,
|
|
PowerIcon,
|
|
PencilEdit02Icon,
|
|
LayoutAlignLeftIcon,
|
|
Settings02Icon,
|
|
TestTube01Icon,
|
|
ZapIcon,
|
|
} from "@hugeicons/core-free-icons";
|
|
import {
|
|
exportConversationRawJsonl,
|
|
exportConversationCsv,
|
|
exportConversationShareGPT,
|
|
} from "@/features/chat/prompt-storage/prompt-storage-dialog";
|
|
import { listStoredChatThreads } from "@/features/chat/utils/chat-history-storage";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
} from "@/components/ui/tooltip";
|
|
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
import { ChevronDown, ChevronsUpDown, MoreHorizontalIcon, Moon, Sun } from "lucide-react";
|
|
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
|
|
import {
|
|
archiveChatItem,
|
|
ChatSearchDialog,
|
|
clearNewChatDraft,
|
|
createChatProject,
|
|
deleteChatProject,
|
|
deleteChatItem,
|
|
moveChatItemToProject,
|
|
renameChatItem,
|
|
renameChatProject,
|
|
unarchiveChatItem,
|
|
useChatRuntimeStore,
|
|
useChatProjects,
|
|
useChatSearchStore,
|
|
useChatSidebarItems,
|
|
type ProjectRecord,
|
|
type SidebarItem,
|
|
} from "@/features/chat";
|
|
import { useSettingsDialogStore } from "@/features/settings";
|
|
import { useEffectiveProfile, UserAvatar } from "@/features/profile";
|
|
import { usePlatformStore } from "@/config/env";
|
|
import { clearAuthTokens, logout } from "@/features/auth";
|
|
import { TOUR_OPEN_EVENT } from "@/features/tour";
|
|
import {
|
|
deleteTrainingRun,
|
|
emitTrainingRunDeleted,
|
|
emitTrainingRunUpdated,
|
|
removeTrainingUnloadGuard,
|
|
renameTrainingRun,
|
|
useTrainingHistorySidebarItems,
|
|
useTrainingRuntimeStore,
|
|
} from "@/features/training";
|
|
import type { TrainingRunSummary } from "@/features/training";
|
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { toast } from "@/lib/toast";
|
|
import { ShutdownDialog } from "@/components/shutdown-dialog";
|
|
import { translate, useT, type TranslationKey } from "@/i18n";
|
|
|
|
const EMPHASIS_MARKER = "__UNSLOTH_I18N_EMPHASIS_MARKER__";
|
|
|
|
type AppT = ReturnType<typeof useT>;
|
|
|
|
function renderEmphasizedTranslation(
|
|
t: AppT,
|
|
key: TranslationKey,
|
|
emphasizedValue: string,
|
|
): ReactNode {
|
|
const translated = t(key, { name: EMPHASIS_MARKER });
|
|
const parts = translated.split(EMPHASIS_MARKER);
|
|
if (parts.length === 1) return translated;
|
|
|
|
const nodes: ReactNode[] = [];
|
|
parts.forEach((part, index) => {
|
|
if (part.length > 0) nodes.push(part);
|
|
if (index < parts.length - 1) {
|
|
nodes.push(<em key={`emphasis-${index}`}>{emphasizedValue}</em>);
|
|
}
|
|
});
|
|
return nodes;
|
|
}
|
|
|
|
function getTourId(pathname: string): string | null {
|
|
if (pathname.startsWith("/studio")) return "studio";
|
|
if (pathname.startsWith("/export")) return "export";
|
|
if (pathname.startsWith("/chat")) return "chat";
|
|
return null;
|
|
}
|
|
|
|
// TestTube01Icon's last 2 paths are interior bubbles; slice to the first
|
|
// 3 (outline + cap + liquid line) to drop them. Original export untouched.
|
|
const TestTubeOutlineIcon = TestTube01Icon.slice(
|
|
0,
|
|
3,
|
|
) as typeof TestTube01Icon;
|
|
|
|
function runStatusDotClass(status: TrainingRunSummary["status"]): string {
|
|
switch (status) {
|
|
case "running":
|
|
return "bg-blue-500 animate-pulse";
|
|
case "completed":
|
|
return "bg-emerald-500";
|
|
case "stopped":
|
|
return "bg-amber-500";
|
|
case "error":
|
|
return "bg-red-500";
|
|
default:
|
|
return "bg-muted-foreground";
|
|
}
|
|
}
|
|
|
|
function formatRelativeShort(iso: string): string {
|
|
const then = new Date(iso).getTime();
|
|
if (Number.isNaN(then)) return "";
|
|
const diffMs = Date.now() - then;
|
|
const s = Math.max(0, Math.floor(diffMs / 1000));
|
|
if (s < 60) return `${s}s`;
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return `${m}m`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}h`;
|
|
const d = Math.floor(h / 24);
|
|
return `${d}d`;
|
|
}
|
|
|
|
function createNavigationNonce(): string {
|
|
if (typeof globalThis.crypto?.randomUUID === "function") {
|
|
return globalThis.crypto.randomUUID();
|
|
}
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
function NavItem({
|
|
icon,
|
|
label,
|
|
active,
|
|
disabled,
|
|
onClick,
|
|
children,
|
|
dataTour,
|
|
className,
|
|
}: {
|
|
icon: typeof ZapIcon;
|
|
label: string;
|
|
active: boolean;
|
|
disabled?: boolean;
|
|
onClick: () => void;
|
|
children?: ReactNode;
|
|
dataTour?: string;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<SidebarMenuItem className={className}>
|
|
<div className="relative">
|
|
<SidebarMenuButton
|
|
tooltip={label}
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
isActive={active}
|
|
data-tour={dataTour}
|
|
className="sidebar-nav-btn h-[33px] rounded-full gap-[8.5px] pl-3 pr-2.5 font-medium group-data-[collapsible=icon]:px-2.5 group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-full group-data-[collapsible=icon]:mx-auto"
|
|
>
|
|
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-icon! shrink-0 group-hover/menu-button:animate-icon-pop" />
|
|
<span className="text-[14.5px] leading-[19px] tracking-nav">{label}</span>
|
|
</SidebarMenuButton>
|
|
</div>
|
|
{children}
|
|
</SidebarMenuItem>
|
|
);
|
|
}
|
|
|
|
export function AppSidebar() {
|
|
const t = useT();
|
|
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle();
|
|
const { pathname, search } = useRouterState({
|
|
select: (s) => ({
|
|
pathname: s.location.pathname,
|
|
search: s.location.search as Record<string, string | undefined>,
|
|
}),
|
|
});
|
|
const { togglePinned, isMobile, setOpenMobile } = useSidebar();
|
|
const navigate = useNavigate();
|
|
|
|
// Auto-close mobile Sheet after navigation
|
|
const closeMobileIfOpen = () => {
|
|
if (isMobile) setOpenMobile(false);
|
|
};
|
|
|
|
const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
|
|
const chatOnly = usePlatformStore((s) => s.isChatOnly());
|
|
const [shutdownOpen, setShutdownOpen] = useState(false);
|
|
|
|
const isChatRoute = pathname.startsWith("/chat");
|
|
const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/");
|
|
const [chatOpen, setChatOpen] = useState(true);
|
|
|
|
const [trainOpen, setTrainOpen] = useState(true);
|
|
const [runsOpen, setRunsOpen] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!isChatRoute) return;
|
|
queueMicrotask(() => setChatOpen(true));
|
|
}, [isChatRoute]);
|
|
useEffect(() => {
|
|
if (!isStudioRoute) return;
|
|
queueMicrotask(() => setRunsOpen(true));
|
|
}, [isStudioRoute]);
|
|
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const [scrolled, setScrolled] = useState(false);
|
|
// Bottom fade hides at the very bottom / for short lists so the last row
|
|
// isn't washed out (Gemini-style).
|
|
const [canScrollDown, setCanScrollDown] = useState(false);
|
|
// Driven only from onScroll + a content-change effect below. No
|
|
// ResizeObserver: its callback-driven setState caused a render loop (React
|
|
// #185). Both setters bail out when unchanged, so neither path can loop.
|
|
const syncScrollState = (el: HTMLDivElement) => {
|
|
const nextScrolled = el.scrollTop > 0;
|
|
setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled));
|
|
const nextCanScrollDown =
|
|
el.scrollHeight - el.scrollTop - el.clientHeight > 1;
|
|
setCanScrollDown((prev) =>
|
|
prev === nextCanScrollDown ? prev : nextCanScrollDown,
|
|
);
|
|
};
|
|
|
|
const isRecipesRoute = pathname.startsWith("/data-recipes");
|
|
const { displayTitle, avatarDataUrl } = useEffectiveProfile();
|
|
|
|
const { projects } = useChatProjects();
|
|
const activeProjectId = isChatRoute
|
|
? ((search.project as string | undefined) ?? null)
|
|
: null;
|
|
const { items: allChatItems, archivedItems: archivedChatItems } =
|
|
useChatSidebarItems({
|
|
enabled: !isStudioRoute,
|
|
requireMessages: false,
|
|
});
|
|
const recentChatItems = useMemo(
|
|
() => allChatItems.filter((item) => !item.projectId),
|
|
[allChatItems],
|
|
);
|
|
const [archivedOpen, setArchivedOpen] = useState(false);
|
|
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
|
|
const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId);
|
|
const activeThreadId = isChatRoute
|
|
? (search.thread as string | undefined) ??
|
|
(search.compare as string | undefined) ??
|
|
storeThreadId ??
|
|
undefined
|
|
: undefined;
|
|
|
|
// Training runs
|
|
const { items: runItems } = useTrainingHistorySidebarItems(
|
|
!chatOnly && isStudioRoute,
|
|
);
|
|
const activeJobId = useTrainingRuntimeStore((s) => s.jobId);
|
|
const currentRunViewActive = useTrainingRuntimeStore((s) => s.currentRunViewActive);
|
|
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
|
|
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId);
|
|
|
|
// Recompute bottom-fade on mount and whenever list height can change
|
|
// (items load, sections toggle, route switch) - onScroll never fires for
|
|
// short, non-scrolling lists. Guarded setState below can't loop.
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const next = el.scrollHeight - el.scrollTop - el.clientHeight > 1;
|
|
setCanScrollDown((prev) => (prev === next ? prev : next));
|
|
}, [
|
|
recentChatItems.length,
|
|
runItems.length,
|
|
projects.length,
|
|
chatOpen,
|
|
trainOpen,
|
|
runsOpen,
|
|
isStudioRoute,
|
|
]);
|
|
|
|
const chatDisabled = isTrainingRunning;
|
|
|
|
function chatSearchForProject(projectId: string | null) {
|
|
if (projectId) {
|
|
return { project: projectId };
|
|
}
|
|
return {
|
|
new: createNavigationNonce(),
|
|
};
|
|
}
|
|
|
|
function openNewChat(projectId = activeProjectId) {
|
|
if (chatDisabled) return;
|
|
clearNewChatDraft();
|
|
setActiveThreadId(null);
|
|
useChatRuntimeStore.getState().setActiveProjectId(projectId);
|
|
navigate({ to: "/chat", search: chatSearchForProject(projectId) });
|
|
closeMobileIfOpen();
|
|
}
|
|
|
|
function openProject(projectId: string) {
|
|
if (chatDisabled) return;
|
|
setActiveThreadId(null);
|
|
useChatRuntimeStore.getState().setActiveProjectId(projectId);
|
|
navigate({ to: "/chat", search: { project: projectId } });
|
|
closeMobileIfOpen();
|
|
}
|
|
|
|
async function handleDeleteThread(item: Parameters<typeof deleteChatItem>[0]) {
|
|
await deleteChatItem(item, activeThreadId, (view) => {
|
|
navigate({
|
|
to: "/chat",
|
|
search: item.projectId
|
|
? { project: item.projectId }
|
|
: { new: view.newThreadNonce },
|
|
});
|
|
});
|
|
}
|
|
|
|
async function handleArchiveThread(item: SidebarItem) {
|
|
try {
|
|
await archiveChatItem(item, activeThreadId, (view) => {
|
|
navigate({
|
|
to: "/chat",
|
|
search: item.projectId
|
|
? { project: item.projectId }
|
|
: { new: view.newThreadNonce },
|
|
});
|
|
});
|
|
} catch (err) {
|
|
toast.error("Failed to archive chat", {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handleUnarchiveThread(item: SidebarItem) {
|
|
try {
|
|
await unarchiveChatItem(item);
|
|
} catch (err) {
|
|
toast.error("Failed to unarchive chat", {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
type RenameTarget =
|
|
| { kind: "chat"; item: SidebarItem; current: string }
|
|
| { kind: "project"; project: ProjectRecord; current: string }
|
|
| { kind: "run"; run: TrainingRunSummary; current: string };
|
|
const [renamingTarget, setRenamingTarget] = useState<RenameTarget | null>(
|
|
null,
|
|
);
|
|
const [renameDraft, setRenameDraft] = useState("");
|
|
const [creatingProject, setCreatingProject] = useState(false);
|
|
const [projectNameDraft, setProjectNameDraft] = useState("");
|
|
const [projectCreateMoveTarget, setProjectCreateMoveTarget] =
|
|
useState<SidebarItem | null>(null);
|
|
const renameTrimmed = renameDraft.trim();
|
|
const nextRunDisplayName = renameTrimmed.length > 0 ? renameTrimmed : null;
|
|
const renameDirty =
|
|
renamingTarget !== null &&
|
|
(renamingTarget.kind === "chat"
|
|
? renameTrimmed.length > 0 && renameTrimmed !== renamingTarget.current
|
|
: renamingTarget.kind === "project"
|
|
? renameTrimmed.length > 0 && renameTrimmed !== renamingTarget.current
|
|
: renameTrimmed.length > 0
|
|
? renameTrimmed !== renamingTarget.current
|
|
: renamingTarget.run.display_name != null);
|
|
|
|
function openRenameChat(item: SidebarItem) {
|
|
setRenameDraft(item.title);
|
|
setRenamingTarget({ kind: "chat", item, current: item.title });
|
|
}
|
|
function openRenameRun(run: TrainingRunSummary) {
|
|
const current = run.display_name ?? run.model_name;
|
|
setRenameDraft(current);
|
|
setRenamingTarget({ kind: "run", run, current });
|
|
}
|
|
async function commitRename() {
|
|
const target = renamingTarget;
|
|
if (!target || !renameDirty) return;
|
|
setRenamingTarget(null);
|
|
if (target.kind === "chat") {
|
|
try {
|
|
await renameChatItem(target.item, renameTrimmed);
|
|
} catch (err) {
|
|
toast.error(translate("shell.toast.failedToRenameChat"), {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (target.kind === "project") {
|
|
try {
|
|
await renameChatProject(target.project.id, renameTrimmed);
|
|
} catch (err) {
|
|
toast.error("Failed to rename project", {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
const updated = await renameTrainingRun(target.run.id, nextRunDisplayName);
|
|
emitTrainingRunUpdated(updated);
|
|
} catch (err) {
|
|
toast.error(translate("shell.toast.failedToRenameRun"), {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
type DeleteTarget =
|
|
| { kind: "chat"; item: SidebarItem }
|
|
| { kind: "project"; project: ProjectRecord }
|
|
| { kind: "run"; run: TrainingRunSummary };
|
|
const [confirmingDelete, setConfirmingDelete] =
|
|
useState<DeleteTarget | null>(null);
|
|
const [deleteProjectFiles, setDeleteProjectFiles] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (confirmingDelete?.kind !== "project") {
|
|
setDeleteProjectFiles(false);
|
|
}
|
|
}, [confirmingDelete]);
|
|
|
|
async function commitDelete() {
|
|
const target = confirmingDelete;
|
|
if (!target) return;
|
|
const shouldDeleteProjectFiles =
|
|
target.kind === "project" && deleteProjectFiles;
|
|
setConfirmingDelete(null);
|
|
if (target.kind === "chat") {
|
|
try {
|
|
await handleDeleteThread(target.item);
|
|
} catch (err) {
|
|
toast.error(translate("shell.toast.failedToDeleteChat"), {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (target.kind === "project") {
|
|
try {
|
|
await deleteChatProject(target.project.id, {
|
|
deleteFiles: shouldDeleteProjectFiles,
|
|
});
|
|
if (activeProjectId === target.project.id) {
|
|
useChatRuntimeStore.getState().setActiveProjectId(null);
|
|
navigate({ to: "/chat", search: { new: createNavigationNonce() } });
|
|
}
|
|
} catch (err) {
|
|
toast.error("Failed to delete project", {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (target.run.status === "running") {
|
|
toast.error(t("shell.toast.cannotDeleteRunningRun"));
|
|
return;
|
|
}
|
|
try {
|
|
await deleteTrainingRun(target.run.id);
|
|
if (selectedHistoryRunId === target.run.id) {
|
|
setSelectedHistoryRunId(null);
|
|
}
|
|
emitTrainingRunDeleted(target.run.id);
|
|
} catch (err) {
|
|
toast.error(translate("shell.toast.failedToDeleteRun"), {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function commitCreateProject() {
|
|
const name = projectNameDraft.trim();
|
|
if (!name) return;
|
|
const moveTarget = projectCreateMoveTarget;
|
|
try {
|
|
const project = await createChatProject(name);
|
|
if (moveTarget) {
|
|
await moveChatItemToProject(moveTarget, project.id);
|
|
if (activeThreadId === moveTarget.id) {
|
|
useChatRuntimeStore.getState().setActiveProjectId(project.id);
|
|
}
|
|
}
|
|
setCreatingProject(false);
|
|
setProjectNameDraft("");
|
|
setProjectCreateMoveTarget(null);
|
|
if (moveTarget) {
|
|
return;
|
|
} else {
|
|
openProject(project.id);
|
|
}
|
|
} catch (err) {
|
|
toast.error(moveTarget ? "Failed to create and move chat" : "Failed to create project", {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function moveChatToProject(item: SidebarItem, projectId: string | null) {
|
|
if (item.projectId === projectId) return;
|
|
try {
|
|
await moveChatItemToProject(item, projectId);
|
|
if (activeThreadId === item.id) {
|
|
useChatRuntimeStore.getState().setActiveProjectId(projectId);
|
|
}
|
|
} catch (err) {
|
|
toast.error("Failed to move chat", {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderChatSidebarItem(
|
|
item: SidebarItem,
|
|
variant: "project" | "recent",
|
|
) {
|
|
const itemClass =
|
|
variant === "project"
|
|
? "group/project-chat-item relative"
|
|
: "group/recent-item relative";
|
|
const actionClass =
|
|
variant === "project"
|
|
? "sidebar-row-action group-hover/project-chat-item:opacity-100 group-hover/project-chat-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
|
|
: "sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto";
|
|
const buttonClass = cn(
|
|
"sidebar-nav-btn h-[33px] cursor-pointer rounded-full pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium",
|
|
// pl-3.5 starts the title at the same x as the Recents label text.
|
|
variant === "project" ? "pl-[39px]" : "pl-3.5",
|
|
variant === "project"
|
|
? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8"
|
|
: "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8",
|
|
);
|
|
|
|
return (
|
|
<SidebarMenuItem key={item.id} className={itemClass}>
|
|
<SidebarMenuButton
|
|
data-testid="recent-thread"
|
|
data-thread-type={item.type}
|
|
data-thread-id={item.id}
|
|
isActive={activeThreadId === item.id}
|
|
className={buttonClass}
|
|
onClick={() => {
|
|
navigate({
|
|
to: "/chat",
|
|
search:
|
|
item.type === "single"
|
|
? {
|
|
thread: item.id,
|
|
...(item.projectId ? { project: item.projectId } : {}),
|
|
}
|
|
: {
|
|
compare: item.id,
|
|
...(item.projectId ? { project: item.projectId } : {}),
|
|
},
|
|
});
|
|
closeMobileIfOpen();
|
|
}}
|
|
>
|
|
<span className="truncate">{item.title}</span>
|
|
</SidebarMenuButton>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label="Chat options"
|
|
className={actionClass}
|
|
>
|
|
<span className="sidebar-row-action-glyph">
|
|
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={1.75} className="size-icon" />
|
|
</span>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
side="bottom"
|
|
align="start"
|
|
sideOffset={0}
|
|
className="unsloth-plus-menu menu-flat-destructive w-56"
|
|
>
|
|
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
|
|
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
|
<span>Rename</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<HugeiconsIcon icon={FolderExportIcon} strokeWidth={1.75} className="size-icon" />
|
|
<span>Move to project</span>
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent
|
|
sideOffset={0}
|
|
alignOffset={-4}
|
|
className="unsloth-plus-menu w-52"
|
|
>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
setProjectCreateMoveTarget(item);
|
|
setProjectNameDraft("");
|
|
setCreatingProject(true);
|
|
}}
|
|
>
|
|
<HugeiconsIcon icon={FolderAddIcon} strokeWidth={1.75} className="size-icon" />
|
|
<span>New project</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
disabled={!item.projectId}
|
|
onSelect={() => void moveChatToProject(item, null)}
|
|
>
|
|
<span>Recents</span>
|
|
</DropdownMenuItem>
|
|
{projects.map((project) => (
|
|
<DropdownMenuItem
|
|
key={project.id}
|
|
disabled={item.projectId === project.id}
|
|
onSelect={() => void moveChatToProject(item, project.id)}
|
|
>
|
|
<HugeiconsIcon icon={Folder01Icon} strokeWidth={1.75} className="size-icon" />
|
|
<span className="truncate">{project.name}</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<HugeiconsIcon icon={Download01Icon} strokeWidth={1.75} className="size-icon" />
|
|
<span>Export</span>
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent sideOffset={8} alignOffset={-4} className="unsloth-plus-menu w-52">
|
|
{[
|
|
{ label: "Raw JSONL", fn: exportConversationRawJsonl },
|
|
{ label: "CSV", fn: exportConversationCsv },
|
|
{ label: "ShareGPT JSONL", fn: exportConversationShareGPT },
|
|
].map(({ label, fn }) => (
|
|
<DropdownMenuItem
|
|
key={label}
|
|
onSelect={async () => {
|
|
try {
|
|
const ids = item.type === "single"
|
|
? [item.id]
|
|
: (await listStoredChatThreads({ pairId: item.id })).map((t) => t.id);
|
|
await Promise.all(ids.map((id) => fn(id)));
|
|
} catch {
|
|
toast.error("Export failed.");
|
|
}
|
|
}}
|
|
>
|
|
{label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
{/* Bulk export and import live in Settings -> Chat -> Data. */}
|
|
<DropdownMenuItem
|
|
onSelect={() =>
|
|
useSettingsDialogStore.getState().openDialog("chat")
|
|
}
|
|
>
|
|
Export all chats…
|
|
</DropdownMenuItem>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
<DropdownMenuItem onSelect={() => void handleArchiveThread(item)}>
|
|
<HugeiconsIcon icon={Archive01Icon} strokeWidth={1.75} className="size-icon" />
|
|
<span>Archive</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
|
|
>
|
|
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
|
<span>Delete</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Sidebar
|
|
collapsible="icon"
|
|
variant="sidebar"
|
|
className="font-heading group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-white dark:group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-background"
|
|
>
|
|
<SidebarHeader className="pl-[17px] pr-3 pt-[14px] pb-[8px] group-data-[collapsible=icon]:px-0">
|
|
{/* Expanded: compact logo + close toggle */}
|
|
<div className="flex items-center justify-between gap-[8.5px] group-data-[collapsible=icon]:hidden">
|
|
<Link
|
|
to="/chat"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
if (chatDisabled) return;
|
|
openNewChat(null);
|
|
}}
|
|
className="flex items-center gap-[6px] select-none"
|
|
aria-label={t("shell.aria.home")}
|
|
>
|
|
<img
|
|
src="/circle-logo-small.png"
|
|
alt="Unsloth"
|
|
className="h-[34px] w-[34px] rounded-full object-cover"
|
|
/>
|
|
<span className="font-heading text-[21px] font-semibold tracking-[0em] dark:tracking-[0.02em] leading-none text-black dark:text-white">
|
|
unsloth
|
|
</span>
|
|
<span className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
|
|
{t("shell.beta")}
|
|
</span>
|
|
</Link>
|
|
{!isMobile && (
|
|
<Tooltip>
|
|
<TooltipPrimitive.Trigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={togglePinned}
|
|
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label={t("shell.aria.closeSidebar")}
|
|
>
|
|
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
|
|
</button>
|
|
</TooltipPrimitive.Trigger>
|
|
<TooltipContent
|
|
side="bottom"
|
|
sideOffset={6}
|
|
className="tooltip-compact"
|
|
>
|
|
{t("shell.aria.closeSidebar")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
|
|
{/* Collapsed: panel icon doubles as expand trigger */}
|
|
{!isMobile && (
|
|
<div className="hidden group-data-[collapsible=icon]:flex h-[33px] items-center justify-center w-full">
|
|
<Tooltip>
|
|
<TooltipPrimitive.Trigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={togglePinned}
|
|
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label={t("shell.aria.openSidebar")}
|
|
>
|
|
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
|
|
</button>
|
|
</TooltipPrimitive.Trigger>
|
|
<TooltipContent
|
|
side="right"
|
|
sideOffset={8}
|
|
className="tooltip-compact"
|
|
>
|
|
{t("shell.aria.openSidebar")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
)}
|
|
</SidebarHeader>
|
|
|
|
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-1.5 pt-[9px] pb-px shrink-0">
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
<NavItem
|
|
icon={PencilEdit02Icon}
|
|
label={t("shell.navigation.newChat")}
|
|
active={
|
|
isChatRoute &&
|
|
!search.thread &&
|
|
!search.compare &&
|
|
!search.project
|
|
}
|
|
disabled={chatDisabled}
|
|
onClick={() => openNewChat(null)}
|
|
/>
|
|
<NavItem
|
|
icon={Search01Icon}
|
|
label={t("shell.navigation.search")}
|
|
active={false}
|
|
onClick={() => {
|
|
// Search is read-only and never runs inference, so it stays
|
|
// available while training (unlike New chat, gated on chatDisabled).
|
|
useChatSearchStore.getState().open();
|
|
closeMobileIfOpen();
|
|
}}
|
|
/>
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
<SidebarContent
|
|
ref={scrollRef}
|
|
onScroll={(e) => syncScrollState(e.currentTarget)}
|
|
className={cn(
|
|
// pb-2 keeps the last row's rounded highlight clear of the
|
|
// overflow clip edge so its bottom corners aren't shaved off.
|
|
"sidebar-scroll-fade gap-0 overflow-y-auto overscroll-contain min-h-0 pb-2",
|
|
scrolled && "is-scrolled",
|
|
)}
|
|
>
|
|
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-1.5 py-0 shrink-0">
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
<NavItem
|
|
icon={Folder01Icon}
|
|
label="Projects"
|
|
active={
|
|
pathname === "/projects" || pathname.startsWith("/projects/")
|
|
}
|
|
onClick={() => {
|
|
navigate({ to: "/projects" });
|
|
closeMobileIfOpen();
|
|
}}
|
|
className="group/projects-item relative"
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label="New project"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setProjectCreateMoveTarget(null);
|
|
setProjectNameDraft("");
|
|
setCreatingProject(true);
|
|
}}
|
|
className="sidebar-row-action group-hover/projects-item:opacity-100 group-hover/projects-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto group-data-[collapsible=icon]:hidden"
|
|
>
|
|
<span className="sidebar-row-action-glyph">
|
|
<HugeiconsIcon
|
|
icon={PlusSignIcon}
|
|
strokeWidth={1.75}
|
|
className="size-4"
|
|
/>
|
|
</span>
|
|
</button>
|
|
</NavItem>
|
|
<NavItem
|
|
icon={DashboardCircleIcon}
|
|
label={t("shell.navigation.hub")}
|
|
active={pathname === "/hub" || pathname.startsWith("/hub/")}
|
|
onClick={() => {
|
|
navigate({ to: "/hub" });
|
|
closeMobileIfOpen();
|
|
}}
|
|
/>
|
|
{/* Train has a labelled section when expanded; plain icon here only when collapsed. */}
|
|
<NavItem
|
|
icon={TestTubeOutlineIcon}
|
|
label={t("shell.navigation.train")}
|
|
active={
|
|
pathname === "/studio" || pathname.startsWith("/studio/")
|
|
}
|
|
disabled={chatOnly}
|
|
onClick={() => {
|
|
if (chatOnly) return;
|
|
navigate({ to: "/studio" });
|
|
closeMobileIfOpen();
|
|
}}
|
|
className="hidden group-data-[collapsible=icon]:block"
|
|
/>
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
<Collapsible open={trainOpen} onOpenChange={setTrainOpen} asChild>
|
|
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
|
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
|
|
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
|
|
{t("shell.navigation.train")}
|
|
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
|
|
</CollapsibleTrigger>
|
|
</SidebarGroupLabel>
|
|
<CollapsibleContent>
|
|
<SidebarGroupContent className="px-1.5">
|
|
<SidebarMenu>
|
|
<NavItem
|
|
icon={TestTubeOutlineIcon}
|
|
label={t("shell.navigation.train")}
|
|
active={pathname === "/studio" || pathname.startsWith("/studio/")}
|
|
disabled={chatOnly}
|
|
onClick={() => {
|
|
if (chatOnly) return;
|
|
navigate({ to: "/studio" });
|
|
closeMobileIfOpen();
|
|
}}
|
|
/>
|
|
<NavItem
|
|
icon={ChefHatIcon}
|
|
label={t("shell.navigation.recipes")}
|
|
active={isRecipesRoute}
|
|
onClick={() => {
|
|
navigate({ to: "/data-recipes" });
|
|
closeMobileIfOpen();
|
|
}}
|
|
/>
|
|
<NavItem
|
|
icon={DownloadSquare01Icon}
|
|
label={t("shell.navigation.export")}
|
|
active={pathname === "/export" || pathname.startsWith("/export/")}
|
|
disabled={chatOnly}
|
|
onClick={() => {
|
|
if (chatOnly) return;
|
|
navigate({ to: "/export" });
|
|
closeMobileIfOpen();
|
|
}}
|
|
/>
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</CollapsibleContent>
|
|
</SidebarGroup>
|
|
</Collapsible>
|
|
|
|
{!isStudioRoute && (
|
|
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
|
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
|
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
|
|
<div className="flex w-full items-center group/sb-collap">
|
|
<CollapsibleTrigger className="cursor-pointer flex flex-1 items-center gap-1 min-w-0">
|
|
{t("shell.navigation.recents")}
|
|
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
|
|
</CollapsibleTrigger>
|
|
</div>
|
|
</SidebarGroupLabel>
|
|
<CollapsibleContent>
|
|
<SidebarGroupContent className="pl-1 pr-1.5">
|
|
<SidebarMenu>
|
|
{recentChatItems.map((item) =>
|
|
renderChatSidebarItem(item, "recent"),
|
|
)}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</CollapsibleContent>
|
|
</SidebarGroup>
|
|
</Collapsible>
|
|
)}
|
|
|
|
{/* Archived chats — hidden on Studio + when nothing is archived */}
|
|
{!isStudioRoute && archivedChatItems.length > 0 && (
|
|
<Collapsible open={archivedOpen} onOpenChange={setArchivedOpen} asChild>
|
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
|
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
|
|
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
|
|
Archived
|
|
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
|
|
</CollapsibleTrigger>
|
|
</SidebarGroupLabel>
|
|
<CollapsibleContent>
|
|
<SidebarGroupContent className="px-1.5">
|
|
<SidebarMenu>
|
|
{archivedChatItems.map((item) => (
|
|
<SidebarMenuItem key={item.id} className="group/archived-item relative">
|
|
<SidebarMenuButton
|
|
data-testid="archived-thread"
|
|
data-thread-type={item.type}
|
|
data-thread-id={item.id}
|
|
isActive={activeThreadId === item.id}
|
|
className="sidebar-nav-btn h-[33px] cursor-pointer rounded-full pl-3.5 pr-4 group-hover/archived-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/archived-item:pr-8 text-[14.5px] leading-[19px] tracking-nav font-medium text-muted-foreground"
|
|
onClick={() => {
|
|
navigate({
|
|
to: "/chat",
|
|
search:
|
|
item.type === "single"
|
|
? { thread: item.id }
|
|
: { compare: item.id },
|
|
});
|
|
closeMobileIfOpen();
|
|
}}
|
|
>
|
|
<span className="truncate">{item.title}</span>
|
|
</SidebarMenuButton>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label="Archived chat options"
|
|
className="sidebar-row-action group-hover/archived-item:opacity-100 group-hover/archived-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
|
|
>
|
|
<span className="sidebar-row-action-glyph">
|
|
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={1.75} className="size-icon" />
|
|
</span>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
side="bottom"
|
|
align="start"
|
|
sideOffset={0}
|
|
className="unsloth-plus-menu menu-flat-destructive w-52"
|
|
>
|
|
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
|
|
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
|
<span>Rename</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => void handleUnarchiveThread(item)}>
|
|
<HugeiconsIcon icon={ArchiveRestoreIcon} strokeWidth={1.75} className="size-icon" />
|
|
<span>Unarchive</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
|
|
>
|
|
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
|
<span>Delete</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</CollapsibleContent>
|
|
</SidebarGroup>
|
|
</Collapsible>
|
|
)}
|
|
|
|
{isStudioRoute && runItems.length > 0 && !chatOnly && (
|
|
<Collapsible open={runsOpen} onOpenChange={setRunsOpen} asChild>
|
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
|
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
|
|
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
|
|
{t("shell.navigation.recents")}
|
|
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
|
|
</CollapsibleTrigger>
|
|
</SidebarGroupLabel>
|
|
<CollapsibleContent>
|
|
<SidebarGroupContent className="pl-1.5 pr-2">
|
|
<SidebarMenu>
|
|
{runItems.map((run) => {
|
|
// Explicit selection wins. Otherwise highlight the active
|
|
// job only while the "Current Run" tab is the view, keeping
|
|
// the Configure tab unhighlighted even though activeJobId
|
|
// stays pinned to the last job.
|
|
const isActiveRun =
|
|
selectedHistoryRunId != null
|
|
? run.id === selectedHistoryRunId
|
|
: currentRunViewActive && run.id === activeJobId;
|
|
return (
|
|
<SidebarMenuItem
|
|
key={run.id}
|
|
className="group/run-item relative"
|
|
>
|
|
<SidebarMenuButton
|
|
isActive={isActiveRun}
|
|
className="sidebar-nav-btn h-auto flex-col items-start gap-0.5 py-[5px] rounded-full pl-3 pr-7 text-[14.5px] tracking-nav font-medium"
|
|
onClick={() => {
|
|
setSelectedHistoryRunId(run.id);
|
|
closeMobileIfOpen();
|
|
}}
|
|
>
|
|
<div className="flex w-full items-center gap-[8.5px]">
|
|
<span
|
|
className={cn(
|
|
"size-1.5 shrink-0 rounded-full",
|
|
runStatusDotClass(run.status),
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
<span className="truncate">
|
|
{run.display_name ?? run.model_name}
|
|
</span>
|
|
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
|
|
{formatRelativeShort(run.started_at)}
|
|
</span>
|
|
</div>
|
|
<span className="w-full truncate pl-3.5 text-xs text-muted-foreground">
|
|
{run.dataset_name}
|
|
</span>
|
|
</SidebarMenuButton>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label={t("shell.aria.runOptions")}
|
|
className="sidebar-row-action group-hover/run-item:opacity-100 group-hover/run-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
|
|
>
|
|
<span className="sidebar-row-action-glyph">
|
|
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
|
|
</span>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
side="bottom"
|
|
align="end"
|
|
sideOffset={0}
|
|
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-full border-0"
|
|
>
|
|
<DropdownMenuItem onSelect={() => openRenameRun(run)}>
|
|
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
|
<span>{t("common.rename")}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
disabled={run.status === "running"}
|
|
onSelect={() =>
|
|
setConfirmingDelete({ kind: "run", run })
|
|
}
|
|
>
|
|
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
|
<span>{t("common.delete")}</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</CollapsibleContent>
|
|
</SidebarGroup>
|
|
</Collapsible>
|
|
)}
|
|
</SidebarContent>
|
|
|
|
<SidebarFooter className="relative group-data-[collapsible=icon]:px-0">
|
|
{/* Fade above the profile box, shown only when there's more list below
|
|
the fold; at the bottom (or short lists) it fades so the last row
|
|
shows fully (Gemini-style). right-2 keeps it clear of the 8px scrollbar gutter. */}
|
|
<div
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"pointer-events-none absolute left-0 right-2 bottom-full h-10 bg-gradient-to-t from-[var(--sidebar)] to-transparent transition-opacity duration-200",
|
|
canScrollDown ? "opacity-100" : "opacity-0",
|
|
)}
|
|
/>
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<SidebarMenuButton
|
|
size="lg"
|
|
aria-label={t("shell.accountMenu", { name: displayTitle })}
|
|
className="sidebar-nav-btn !h-[44px] -my-[3px] gap-[9px] px-2 py-[3px] rounded-[14px]"
|
|
>
|
|
<div className="flex shrink-0 items-center">
|
|
<UserAvatar
|
|
name={displayTitle}
|
|
imageUrl={avatarDataUrl}
|
|
size="sm"
|
|
className="!size-[32px]"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-px leading-tight group-data-[collapsible=icon]:hidden">
|
|
<span className="truncate font-heading text-[13.5px] tracking-[0.025em] dark:tracking-[0.04em] font-semibold text-nav-fg">{displayTitle}</span>
|
|
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span>
|
|
</div>
|
|
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
|
|
</SidebarMenuButton>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
side="top"
|
|
align="center"
|
|
sideOffset={8}
|
|
className="app-user-menu menu-soft-surface-up ring-0 w-[16rem] px-2.5 py-2.5 font-heading rounded-[20px] border-0"
|
|
>
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem
|
|
onSelect={() => useSettingsDialogStore.getState().openDialog()}
|
|
>
|
|
<HugeiconsIcon icon={Settings02Icon} strokeWidth={1.75} className="size-icon" />
|
|
<span>{t("shell.navigation.settings")}</span>
|
|
<DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => useSettingsDialogStore.getState().openDialog("api-keys")}
|
|
>
|
|
<HugeiconsIcon icon={Globe02Icon} strokeWidth={1.75} className="size-[18px]" />
|
|
<span>{t("shell.navigation.api")}</span>
|
|
<span className="ml-auto rounded-[6px] border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
|
|
{t("common.new")}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
ref={anchorRef as React.Ref<HTMLDivElement>}
|
|
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
|
|
>
|
|
{isDark ? <Sun strokeWidth={1.75} className="size-icon" /> : <Moon strokeWidth={1.75} className="size-icon" />}
|
|
<span>
|
|
{isDark
|
|
? t("shell.navigation.lightMode")
|
|
: t("shell.navigation.darkMode")}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
disabled={!getTourId(pathname)}
|
|
onSelect={() => {
|
|
const tourId = getTourId(pathname);
|
|
if (!tourId) return;
|
|
window.dispatchEvent(
|
|
new CustomEvent(TOUR_OPEN_EVENT, {
|
|
detail: { id: tourId },
|
|
}),
|
|
);
|
|
}}
|
|
>
|
|
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
|
|
<span>{t("shell.navigation.guidedTour")}</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
<DropdownMenuSeparator className="mx-2.5! my-2.5! h-0! border-t border-border/70 bg-transparent!" />
|
|
<DropdownMenuItem
|
|
onSelect={() => useSettingsDialogStore.getState().openDialog("about")}
|
|
>
|
|
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-icon" />
|
|
<span>{t("common.help")}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={async () => {
|
|
// Best-effort server revocation; ignore network errors so
|
|
// the local clear still runs and the user lands on /login.
|
|
try {
|
|
await logout();
|
|
} catch {
|
|
clearAuthTokens();
|
|
}
|
|
void navigate({ to: "/login" });
|
|
}}
|
|
>
|
|
<HugeiconsIcon icon={Logout05Icon} strokeWidth={1.75} className="size-icon" />
|
|
<span>{t("shell.navigation.logOut")}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
|
|
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-icon" />
|
|
<span>{t("common.shutdown")}</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarFooter>
|
|
</Sidebar>
|
|
<ChatSearchDialog />
|
|
<ShutdownDialog
|
|
open={shutdownOpen}
|
|
onOpenChange={setShutdownOpen}
|
|
onAfterShutdown={removeTrainingUnloadGuard}
|
|
/>
|
|
<Dialog
|
|
open={confirmingDelete !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setConfirmingDelete(null);
|
|
setDeleteProjectFiles(false);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="menu-flat-destructive corner-squircle dialog-soft-surface sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{confirmingDelete?.kind === "run"
|
|
? t("shell.dialog.deleteRun.title")
|
|
: confirmingDelete?.kind === "project"
|
|
? "Delete project"
|
|
: t("shell.dialog.deleteChat.title")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{confirmingDelete?.kind === "run" ? (
|
|
renderEmphasizedTranslation(
|
|
t,
|
|
"shell.dialog.deleteRun.description",
|
|
confirmingDelete.run.display_name ??
|
|
confirmingDelete.run.model_name,
|
|
)
|
|
) : confirmingDelete?.kind === "chat" ? (
|
|
renderEmphasizedTranslation(
|
|
t,
|
|
"shell.dialog.deleteChat.description",
|
|
confirmingDelete.item.title,
|
|
)
|
|
) : confirmingDelete?.kind === "project" ? (
|
|
<>
|
|
Delete{" "}
|
|
<span className="font-medium text-foreground">
|
|
"{confirmingDelete.project.name}"
|
|
</span>
|
|
? Its chats will be permanently deleted.
|
|
</>
|
|
) : null}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{confirmingDelete?.kind === "project" ? (
|
|
<div className="flex items-start justify-between gap-4 rounded-md border border-border/60 bg-muted/35 px-3 py-2.5">
|
|
<label htmlFor="delete-project-files" className="min-w-0 space-y-1">
|
|
<span className="block text-sm font-medium text-foreground">
|
|
Delete files and sandbox folder
|
|
</span>
|
|
<span className="block break-words text-xs leading-5 text-muted-foreground">
|
|
{confirmingDelete.project.rootPath
|
|
? confirmingDelete.project.rootPath
|
|
: "The project workspace folder will be removed from disk."}
|
|
</span>
|
|
</label>
|
|
<Switch
|
|
id="delete-project-files"
|
|
checked={deleteProjectFiles}
|
|
onCheckedChange={setDeleteProjectFiles}
|
|
aria-label="Delete project files and sandbox folder"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => setConfirmingDelete(null)}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={() => void commitDelete()}
|
|
>
|
|
{confirmingDelete?.kind === "project" && deleteProjectFiles
|
|
? "Delete all"
|
|
: t("common.delete")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<Dialog
|
|
open={renamingTarget !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setRenamingTarget(null);
|
|
}}
|
|
>
|
|
<DialogContent className="corner-squircle dialog-soft-surface sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{renamingTarget?.kind === "run"
|
|
? t("shell.dialog.renameRun.title")
|
|
: renamingTarget?.kind === "project"
|
|
? "Rename project"
|
|
: t("shell.dialog.renameChat.title")}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<Input
|
|
value={renameDraft}
|
|
onChange={(event) => setRenameDraft(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
void commitRename();
|
|
}
|
|
}}
|
|
autoFocus
|
|
maxLength={120}
|
|
placeholder={
|
|
renamingTarget?.kind === "run"
|
|
? t("shell.dialog.renameRun.placeholder")
|
|
: renamingTarget?.kind === "project"
|
|
? "Project name"
|
|
: t("shell.dialog.renameChat.placeholder")
|
|
}
|
|
aria-label={
|
|
renamingTarget?.kind === "run"
|
|
? t("shell.dialog.renameRun.placeholder")
|
|
: renamingTarget?.kind === "project"
|
|
? "Project name"
|
|
: t("shell.dialog.renameChat.placeholder")
|
|
}
|
|
className="focus-visible:border-input focus-visible:ring-0"
|
|
/>
|
|
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => setRenamingTarget(null)}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => void commitRename()}
|
|
disabled={!renameDirty}
|
|
>
|
|
{t("common.save")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<Dialog
|
|
open={creatingProject}
|
|
onOpenChange={(open) => {
|
|
setCreatingProject(open);
|
|
if (!open) {
|
|
setProjectNameDraft("");
|
|
setProjectCreateMoveTarget(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="corner-squircle dialog-soft-surface sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{projectCreateMoveTarget ? "Move to new project" : "New project"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<Input
|
|
value={projectNameDraft}
|
|
onChange={(event) => setProjectNameDraft(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
void commitCreateProject();
|
|
}
|
|
}}
|
|
autoFocus
|
|
maxLength={120}
|
|
placeholder="Project name"
|
|
aria-label="Project name"
|
|
className="focus-visible:border-input focus-visible:ring-0"
|
|
/>
|
|
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setCreatingProject(false);
|
|
setProjectCreateMoveTarget(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => void commitCreateProject()}
|
|
disabled={!projectNameDraft.trim()}
|
|
>
|
|
Create
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|