Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app-next/src/components/layout/main-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function MainContent({ children }: { children: React.ReactNode }) {
return (
<main
className={cn(
"flex-1 overflow-x-hidden pt-[98px] transition-all duration-300",
"flex-1 overflow-x-hidden pt-[150px] transition-all duration-300 md:pt-[98px]",
!isHomePage && "lg:ml-64",
!isHomePage && isCollapsed && "lg:ml-12",
)}
Expand Down
40 changes: 40 additions & 0 deletions app-next/src/components/workspace/workspace-content-wrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import { usePathname } from "next/navigation";
import { useWorkspace } from "@/contexts/workspace-context";
import { cn } from "@/lib/utils";

/**
* Wraps page content with dynamic right margin that matches the
* fixed-position workspace panel width. This ensures the header
* stays full-width while content doesn't slide under the panel.
*/
export function WorkspaceContentWrapper({
children,
}: {
children: React.ReactNode;
}) {
const { entity, isPanelCollapsed } = useWorkspace();
const pathname = usePathname();

// Mirror the same visibility logic as WorkspacePanel so margin is removed
// immediately when pathname changes (before async clearWorkspace fires).
const normalizedPath = (pathname ?? "").replace(/^\/[a-z]{2}(\/|$)/, "/");
const entityBasePath = entity?.url.split("?")[0] ?? null;
const hasPanelContent =
entity !== null &&
entityBasePath !== null &&
normalizedPath.startsWith(entityBasePath);

return (
<div
className={cn(
"min-w-0 transition-[margin] duration-300",
hasPanelContent && !isPanelCollapsed && "xl:mr-72",
hasPanelContent && isPanelCollapsed && "xl:mr-12",
)}
>
{children}
</div>
);
}
305 changes: 305 additions & 0 deletions app-next/src/components/workspace/workspace-inline-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
"use client";

import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
ChevronLeft,
ChevronRight,
Menu,
X,
ArrowLeft,
Clock,
BarChart3,
Settings2,
Tags,
FileText,
LineChart,
Download,
GitCompareArrows,
ExternalLink,
Database,
Layers,
} from "lucide-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ENTITY_ICONS, entityColors } from "@/constants";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useWorkspace, type EntityType } from "@/contexts/workspace-context";

// ─── Icon lookup ─────────────────────────────────────────────────────────────
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
BarChart3,
Settings2,
Tags,
FileText,
LineChart,
Download,
GitCompareArrows,
ExternalLink,
Database,
Layers,
};

function IconByName({ name, className }: { name: string; className?: string }) {
const Icon = ICON_MAP[name];
if (!Icon) return null;
return <Icon className={className} />;
}

// ─── Entity color helper ──────────────────────────────────────────────────────
const ENTITY_COLOR_MAP: Record<EntityType, string> = {
run: entityColors.run,
dataset: entityColors.data,
task: entityColors.task,
flow: entityColors.flow,
collection: entityColors.collections,
benchmark: entityColors.benchmarks,
measure: entityColors.measures,
};

const ENTITY_ICON_MAP: Record<EntityType, keyof typeof ENTITY_ICONS> = {
run: "run",
dataset: "dataset",
task: "task",
flow: "flow",
collection: "collection",
benchmark: "benchmark",
measure: "measure",
};

// ─── Shared panel content ─────────────────────────────────────────────────────
function PanelContent({ showRecent }: { showRecent: boolean }) {
const { entity, sections, recentEntities } = useWorkspace();

return (
<div className="space-y-4">
{/* On This Page */}
{sections.length > 0 && (
<div className="bg-card rounded-lg border p-4 shadow-sm">
<h3
className="mb-3 text-sm font-semibold"
style={{
color: entity ? ENTITY_COLOR_MAP[entity.type] : entityColors.run,
}}
>
On This Page
</h3>
<nav className="space-y-0.5">
{sections.map((section) => {
const isPageLink = section.href && !section.href.startsWith("#");
const Comp = isPageLink ? Link : "a";
return (
<Comp
key={section.id}
href={section.href || `#${section.id}`}
className="text-muted-foreground hover:bg-accent hover:text-accent-foreground flex items-center justify-between rounded-md px-3 py-2 text-sm transition-colors dark:hover:bg-slate-700 dark:hover:text-white"
>
<span className="flex items-center gap-2">
<IconByName name={section.iconName} className="h-4 w-4" />
{section.label}
</span>
{section.count != null && section.count > 0 && (
<Badge variant="secondary" className="text-xs">
{section.count.toLocaleString()}
</Badge>
)}
</Comp>
);
})}
</nav>
{entity?.resetHref && (
<div className="mt-3 border-t pt-3">
<Link
href={entity.resetHref}
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors"
>
<X className="h-4 w-4" />
Reset
</Link>
</div>
)}
</div>
)}

{/* Navigation */}
{entity && (
<div className="bg-card rounded-lg border p-4 shadow-sm">
<h3
className="mb-3 text-sm font-semibold"
style={{ color: ENTITY_COLOR_MAP[entity.type] }}
>
Navigation
</h3>
<nav className="space-y-0.5">
<Link
href={`/${entity.type}s`}
className="text-muted-foreground hover:bg-accent hover:text-accent-foreground flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors dark:hover:bg-slate-700 dark:hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
Back to Search
</Link>
<Link
href={`/${entity.type}s`}
className="text-muted-foreground hover:bg-accent hover:text-accent-foreground flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors dark:hover:bg-slate-700 dark:hover:text-white"
>
<FontAwesomeIcon
icon={ENTITY_ICONS[ENTITY_ICON_MAP[entity.type]]}
className="h-4 w-4"
style={{ color: ENTITY_COLOR_MAP[entity.type] }}
/>
All {entity.type.charAt(0).toUpperCase() + entity.type.slice(1)}s
</Link>
</nav>
</div>
)}

{/* Recent — only on runs/compare */}
{showRecent && recentEntities.length > 1 && (
<div className="bg-card rounded-lg border p-4 shadow-sm">
<h3 className="text-muted-foreground mb-3 flex items-center gap-1.5 text-xs font-semibold tracking-wider uppercase">
<Clock className="h-3 w-3" />
Recent
</h3>
<nav className="space-y-0.5">
{recentEntities.slice(0, 8).map((ent) => {
const isCurrent =
entity &&
ent.type === entity.type &&
String(ent.id) === String(entity.id);
return (
<Link
key={`${ent.type}:${ent.id}`}
href={ent.url}
className={`flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors ${
isCurrent
? "bg-accent font-medium"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-slate-700 dark:hover:text-white"
}`}
>
<FontAwesomeIcon
icon={ENTITY_ICONS[ENTITY_ICON_MAP[ent.type]]}
className="h-3 w-3 shrink-0"
style={{ color: ENTITY_COLOR_MAP[ent.type] }}
/>
<span className="truncate">{ent.title}</span>
</Link>
);
})}
</nav>
</div>
)}
</div>
);
}

// ═══════════════════════════════════════════════════════════════════════════════
// WorkspaceInlinePanel — renders as a sticky sidebar inside a flex layout.
// Place it as a sibling of the main content div inside a `relative flex gap-8`
// container that starts AFTER the entity header.
// ═══════════════════════════════════════════════════════════════════════════════
export function WorkspaceInlinePanel() {
const {
entity,
isPanelCollapsed: isCollapsed,
setIsPanelCollapsed: setIsCollapsed,
} = useWorkspace();

const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();

// Only show "Recent" on runs/compare
const normalizedPath = (pathname ?? "").replace(/^\/[a-z]{2}(\/|$)/, "/");
const showRecent = normalizedPath.startsWith("/runs/compare");

// Don't render if no entity context
if (!entity) return null;

const entityColor = ENTITY_COLOR_MAP[entity.type];

return (
<>
{/* ── Mobile floating button ─────────────────────────────────── */}
<div className="fixed right-6 bottom-6 z-50 xl:hidden">
<Button
onClick={() => setMobileOpen(!mobileOpen)}
size="lg"
className="shadow-lg"
style={{ backgroundColor: entityColor }}
>
{mobileOpen ? (
<X className="mr-2 h-5 w-5" />
) : (
<Menu className="mr-2 h-5 w-5" />
)}
{mobileOpen ? "Close" : "On This Page"}
</Button>
</div>

{/* ── Mobile slide-out panel ─────────────────────────────────── */}
{mobileOpen && (
<>
<div
className="fixed inset-0 z-40 bg-black/50 xl:hidden"
onClick={() => setMobileOpen(false)}
/>
<div className="bg-background fixed top-0 right-0 bottom-0 z-50 w-80 shadow-2xl xl:hidden">
<div className="flex h-full flex-col overflow-y-auto p-6">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold">On This Page</h2>
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
<PanelContent showRecent={showRecent} />
</div>
</div>
</>
)}

{/* ── Desktop sticky sidebar ─────────────────────────────────── */}
<aside
className={`hidden shrink-0 transition-all duration-300 xl:block ${
isCollapsed ? "w-12" : "w-72"
}`}
>
{isCollapsed ? (
<div className="sticky top-28 pt-2">
<Button
onClick={() => setIsCollapsed(false)}
variant="outline"
size="icon"
className="bg-background hover:bg-accent shadow-md"
title="Expand panel"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
) : (
<div
className="sticky top-28 w-72 space-y-4 overflow-y-auto pb-8"
style={{ maxHeight: "calc(100vh - 8rem)" }}
>
<div className="flex justify-end">
<Button
onClick={() => setIsCollapsed(true)}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title="Collapse panel"
>
<ChevronRight className="mr-1 h-4 w-4" />
Hide
</Button>
</div>
<PanelContent showRecent={showRecent} />
</div>
)}
</aside>
</>
);
}
Loading