unsloth/studio/frontend/src/components/app-sidebar.tsx
Nilay 911ceba7fa
Studio: new-chat shortcut, composer draft autosave, archive threads (#5771)
* 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>
2026-06-12 13:36:31 +01:00

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">
&quot;{confirmingDelete.project.name}&quot;
</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>
</>
);
}