fix(ui): refine task side panel tabs (#14949)

This commit is contained in:
Tonio authored and GitHub committed 2026-10-19 19:26:23 -07:00
1 parent 7b9334cd10
commit 8723580071
7 files changed
+288 -67

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ The components exported by `@/components/side-panel` are controlled, domain-free
Never put React nodes or fetched content in a persisted `SidePanelTabRecord`. Project icons and live status into `SidePanelTabItem` at render time.
`SidePanelTabs` also accepts `appearance="streamlined-task"` for the experimental task-detail shell. That appearance preserves the approved pre-rebase Codex-inspired treatment—equal-width text tabs, separated surfaces, edge fades, and hover-revealed close actions—while the default appearance remains master's portable side-panel chrome.
`SidePanelTabs` also accepts `appearance="streamlined-task"` for the experimental task-detail shell. Its left-aligned text tabs share one width: 100px when every label fits, or 125px when any label needs more room. They shrink equally as the panel narrows, then scroll once the usable width floor is reached. Clipped labels fade. The tabs keep a subtle active fill and omit dividers. The active tab shows its round close action; inactive tabs reveal theirs on hover. Every close action has its own hover highlight. The default appearance remains the portable side-panel chrome.
Use `headerSize="task-detail"` on `SidePanelFrame` when the panel shares the 60px task breadcrumb row. It keeps the task-detail footer treatment while matching the default portable header height.
+28 -10
View File
@@ -12,6 +12,7 @@ export interface SidePanelTabProps {
active: boolean;
closable?: boolean;
disabled?: boolean;
suppressTooltip?: boolean;
tabRef?: Ref<HTMLButtonElement>;
dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>;
onSelect: () => void;
@@ -31,6 +32,7 @@ export function SidePanelTab({
active,
closable = true,
disabled = false,
suppressTooltip = false,
tabRef,
dragHandleProps,
onSelect,
@@ -44,6 +46,7 @@ export function SidePanelTab({
const wrapperRef = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLSpanElement>(null);
const [labelIsTruncated, setLabelIsTruncated] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const sizingKey = `${appearance}:${label}:${icon ? "icon" : "no-icon"}:${status ? "status" : "no-status"}:${closable ? "closable" : "fixed"}`;
const [stableWidth, setStableWidth] = useState<{ key: string; width: number } | null>(null);
const hasStableWidth = stableWidth?.key === sizingKey;
@@ -69,6 +72,10 @@ export function SidePanelTab({
return () => observer.disconnect();
}, [label]);
useEffect(() => {
if (suppressTooltip || !labelIsTruncated) setTooltipOpen(false);
}, [labelIsTruncated, suppressTooltip]);
return (
<div
ref={wrapperRef}
@@ -78,21 +85,24 @@ export function SidePanelTab({
style={appearance === "default" && hasStableWidth ? { width: stableWidth.width } : undefined}
className={cn(
appearance === "streamlined-task"
? "group/side-panel-tab relative mx-1.5 flex h-7 min-w-0 flex-1 basis-0 items-center rounded-md border border-transparent"
? "group/side-panel-tab relative flex h-7 w-full min-w-0 items-center rounded-md border border-transparent"
: "group/side-panel-tab relative flex h-(--side-panel-tab-height) min-w-0 shrink-0 items-center rounded-(--side-panel-tab-radius) border border-transparent",
"side-panel-tab-motion",
active
? appearance === "streamlined-task"
? "text-foreground hover:bg-accent/50"
? "bg-(--side-panel-streamlined-tab-active-bg) text-foreground"
: "bg-(--side-panel-tab-active-bg) text-accent-foreground"
: appearance === "streamlined-task"
? "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
? "text-muted-foreground hover:bg-(--side-panel-streamlined-tab-hover-bg) hover:text-foreground"
: "text-muted-foreground hover:bg-(--side-panel-tab-hover-bg) hover:text-foreground",
disabled && "opacity-50",
className,
)}
>
<Tooltip open={labelIsTruncated ? undefined : false}>
<Tooltip
open={labelIsTruncated && !suppressTooltip && tooltipOpen}
onOpenChange={(open) => setTooltipOpen(open && labelIsTruncated && !suppressTooltip)}
>
<TooltipTrigger asChild>
<button
{...dragHandleProps}
@@ -112,7 +122,10 @@ export function SidePanelTab({
onKeyDown={onKeyDown}
className={cn(
appearance === "streamlined-task"
? "flex h-full w-full min-w-0 items-center justify-start rounded-md px-3 text-sm font-medium outline-none"
? cn(
"flex h-full w-full min-w-0 items-center rounded-md pl-(--side-panel-streamlined-tab-padding-left) text-sm font-medium outline-none",
closable ? "pr-6" : "pr-1.5",
)
: "flex h-full w-full min-w-0 items-center gap-1.5 rounded-(--side-panel-tab-radius) py-1.5 pl-2 text-xs font-medium outline-none",
appearance === "default" && (closable && (!hasStableWidth || active) ? "pr-7" : "pr-2.5"),
"focus-visible:ring-2 focus-visible:ring-ring/60",
@@ -125,17 +138,19 @@ export function SidePanelTab({
data-truncated={labelIsTruncated ? "true" : undefined}
className={cn(
appearance === "streamlined-task"
? "side-panel-tab-label-close-fade task-detail-pane-tab-label min-w-0 flex-1 overflow-hidden whitespace-nowrap text-center"
? "min-w-0 flex-auto overflow-hidden whitespace-nowrap text-left"
: "overflow-hidden whitespace-nowrap",
appearance === "default" && (
closable && hasStableWidth && !active
? "max-w-(--side-panel-tab-label-expanded-max-width)"
: "max-w-(--side-panel-tab-label-max-width)"
),
appearance === "default" && labelIsTruncated && "side-panel-tab-label-fade",
labelIsTruncated && "side-panel-tab-label-fade",
)}
>
{label}
{appearance === "streamlined-task" ? (
<span data-side-panel-tab-label-content className="inline-block">{label}</span>
) : label}
</span>
{status ? (
<span className={cn(
@@ -147,7 +162,7 @@ export function SidePanelTab({
) : null}
</button>
</TooltipTrigger>
{labelIsTruncated ? <TooltipContent side="bottom">{label}</TooltipContent> : null}
{labelIsTruncated && !suppressTooltip ? <TooltipContent side="bottom">{label}</TooltipContent> : null}
</Tooltip>
{closable && onClose && (appearance === "streamlined-task" || active) ? (
<button
@@ -166,7 +181,10 @@ export function SidePanelTab({
className={cn(
"side-panel-tab-close-motion absolute flex items-center justify-center text-muted-foreground outline-none hover:text-foreground",
appearance === "streamlined-task"
? "right-0 top-1/2 z-20 size-5 -translate-y-1/2 rounded-sm opacity-0 hover:bg-accent focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/60 group-hover/side-panel-tab:opacity-100"
? cn(
"right-0.5 top-1/2 z-20 size-5 -translate-y-1/2 rounded-full hover:bg-accent focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/60",
active ? "opacity-100" : "opacity-0 group-hover/side-panel-tab:opacity-100",
)
: "right-1 size-6 rounded-lg hover:bg-background/70 focus-visible:ring-2 focus-visible:ring-ring/60",
)}
>
@@ -101,7 +101,7 @@ describe("SidePanelTabs", () => {
expect(separators[0]?.parentElement?.querySelector('[data-side-panel-tab-target="inactive-two"]')).not.toBeNull();
});
it("restores the pre-rebase Streamlined UI tab treatment", () => {
it("highlights the active Streamlined tab and lets capped tabs shrink together", () => {
act(() => {
root.render(
<TooltipProvider>
@@ -124,42 +124,53 @@ describe("SidePanelTabs", () => {
expect(tabs).toHaveLength(2);
expect(tabs[0]?.className).toContain("text-sm");
expect(tabs[0]?.querySelector("svg")).toBeNull();
expect(container.querySelectorAll('[data-side-panel-tab-separator="true"]')).toHaveLength(1);
expect(container.querySelectorAll('[data-side-panel-tab-separator="true"]')).toHaveLength(0);
const propertiesWrapper = container.querySelector<HTMLElement>('[data-side-panel-tab-wrapper="properties"]');
expect(propertiesWrapper?.className).toContain("mx-1.5");
expect(propertiesWrapper?.className).toContain("h-7");
expect(propertiesWrapper?.className).toContain("text-foreground");
expect(propertiesWrapper?.className).toContain("hover:bg-accent/50");
expect(propertiesWrapper?.className).toContain("bg-(--side-panel-streamlined-tab-active-bg)");
expect(propertiesWrapper?.className).not.toContain("bg-muted");
expect(container.querySelector<HTMLElement>('[data-side-panel-tab-wrapper="document:plan"]')?.className)
.toContain("hover:bg-(--side-panel-streamlined-tab-hover-bg)");
expect(propertiesWrapper?.style.width).toBe("");
expect(propertiesWrapper?.parentElement?.className)
.toContain("min-w-(--side-panel-streamlined-tab-min-width)");
.toContain("mx-0.75");
expect(propertiesWrapper?.parentElement?.className).toContain("side-panel-streamlined-tab");
expect(propertiesWrapper?.parentElement?.className).toContain("shrink");
expect(propertiesWrapper?.parentElement?.className).not.toContain("shrink-0");
expect(propertiesWrapper?.parentElement?.className)
.toContain("max-w-(--side-panel-streamlined-tab-max-width)");
const planLabel = container.querySelector<HTMLElement>('[data-side-panel-tab-target="document:plan"] span');
expect(planLabel?.className).toContain("task-detail-pane-tab-label");
expect(planLabel?.className).toContain("side-panel-tab-label-close-fade");
expect(planLabel?.className).toContain("text-center");
expect(planLabel?.className).toContain("text-left");
expect(planLabel?.className).toContain("flex-auto");
expect(tabs[0]?.className).toContain("pl-(--side-panel-streamlined-tab-padding-left)");
expect(tabs[0]?.className).toContain("pr-6");
const tabList = container.querySelector<HTMLElement>('[role="tablist"]');
expect(tabList?.className).toContain("overflow-x-auto");
expect(tabList?.className).not.toContain("overflow-hidden");
expect(tabList?.firstElementChild?.className).toContain("w-max");
expect(tabList?.firstElementChild?.className).toContain("min-w-full");
expect(tabList?.firstElementChild?.className).toContain("w-full");
expect(tabList?.firstElementChild?.className).toContain("justify-start");
expect((tabList?.firstElementChild as HTMLElement)?.style.getPropertyValue("--side-panel-streamlined-tab-count")).toBe("2");
const planCloseButton = container.querySelector<HTMLButtonElement>('button[aria-label="Close Implementation plan"]');
expect(planCloseButton?.className).toContain("opacity-0");
expect(planCloseButton?.className).toContain("right-0");
expect(planCloseButton?.className).toContain("right-0.5");
expect(planCloseButton?.className).toContain("rounded-full");
expect(planCloseButton?.className).toContain("hover:bg-accent");
expect(planCloseButton?.className).toContain("side-panel-tab-close-motion");
expect(planCloseButton?.className).not.toContain("side-panel-tab-motion");
expect(planCloseButton?.className).not.toContain("group-focus-within/side-panel-tab:opacity-100");
const propertiesCloseButton = container.querySelector<HTMLButtonElement>('button[aria-label="Close Properties"]');
expect(propertiesCloseButton?.className).toContain("opacity-100");
expect(propertiesCloseButton?.className).toContain("hover:bg-accent");
const addButton = container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]');
expect(addButton?.className).toContain("h-(--side-panel-tab-height)");
expect(addButton?.className).toContain("w-(--side-panel-tab-height)");
expect(addButton?.className).toContain("hover:bg-accent");
});
it("lets a lone Streamlined tab fill the space before the add action", () => {
it("keeps a lone Streamlined tab at the preferred minimum and below the cap", () => {
act(() => {
root.render(
<TooltipProvider>
@@ -188,12 +199,11 @@ describe("SidePanelTabs", () => {
'[data-side-panel-tab-wrapper="properties"]',
)?.parentElement;
expect(tabTrack?.className).toContain("w-full");
expect(tabTrack?.className).not.toContain("w-max");
expect(propertiesWrapper?.className).toContain("max-w-none");
expect(propertiesWrapper?.className).toContain("flex-1");
expect(propertiesWrapper?.className).not.toContain(
"max-w-(--side-panel-streamlined-tab-max-width)",
);
expect(tabTrack?.className).toContain("justify-start");
expect(propertiesWrapper?.className).not.toContain("w-max");
expect(propertiesWrapper?.className).toContain("side-panel-streamlined-tab");
expect(propertiesWrapper?.className).toContain("max-w-(--side-panel-streamlined-tab-max-width)");
expect(propertiesWrapper?.className).not.toContain("flex-1");
});
it("enables the tab flyout only while its label is truncated", () => {
@@ -218,15 +228,17 @@ describe("SidePanelTabs", () => {
renderLabel("Properties");
const tab = container.querySelector<HTMLButtonElement>('[data-side-panel-tab-target="properties"]')!;
const label = tab.querySelector<HTMLElement>(".task-detail-pane-tab-label")!;
const label = tab.querySelector<HTMLElement>("span")!;
expect(tab.dataset.sidePanelTabTooltip).toBe("disabled");
expect(label.dataset.truncated).toBeUndefined();
expect(label.className).not.toContain("side-panel-tab-label-fade");
scrollWidth.mockReturnValue(128);
clientWidth.mockReturnValue(72);
renderLabel("Properties panel");
expect(tab.dataset.sidePanelTabTooltip).toBe("enabled");
expect(label.dataset.truncated).toBe("true");
expect(label.className).toContain("side-panel-tab-label-fade");
});
it("fades the right edge only while more Streamlined tabs remain", () => {
+121 -30
View File
@@ -1,21 +1,26 @@
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ButtonHTMLAttributes,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
@@ -24,7 +29,7 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS as DndCSS } from "@dnd-kit/utilities";
import { Plus } from "lucide-react";
import { Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { SidePanelTab } from "./SidePanelTab";
@@ -33,8 +38,8 @@ import type { SidePanelTabItem } from "./types";
interface SortableSidePanelTabProps {
tab: SidePanelTabItem;
active: boolean;
dragging: boolean;
appearance: "default" | "streamlined-task";
fillAvailableWidth: boolean;
showLeadingSeparator: boolean;
onSelect: () => void;
onClose: () => void;
@@ -45,8 +50,8 @@ interface SortableSidePanelTabProps {
function SortableSidePanelTab({
tab,
active,
dragging,
appearance,
fillAvailableWidth,
showLeadingSeparator,
onSelect,
onClose,
@@ -58,31 +63,22 @@ function SortableSidePanelTab({
<div
ref={sortable.setNodeRef}
style={{
transform: DndCSS.Transform.toString(sortable.transform),
// Tabs have different widths; applying the sortable scale stretches them during reordering.
transform: DndCSS.Translate.toString(sortable.transform),
transition: sortable.transition,
}}
className={cn(
appearance === "streamlined-task"
? cn(
"relative flex flex-1 items-center",
fillAvailableWidth
? "min-w-0 max-w-none basis-auto"
: "min-w-(--side-panel-streamlined-tab-min-width) max-w-(--side-panel-streamlined-tab-max-width) basis-0",
)
? "side-panel-streamlined-tab relative mx-0.75 flex max-w-(--side-panel-streamlined-tab-max-width) shrink items-center"
: "relative",
sortable.isDragging && "z-20 opacity-80",
sortable.isDragging && (appearance === "streamlined-task" ? "opacity-0" : "z-20 opacity-80"),
)}
>
{showLeadingSeparator ? (
<span
aria-hidden
data-side-panel-tab-separator="true"
className={cn(
"pointer-events-none absolute w-px bg-border",
appearance === "streamlined-task"
? "left-0 top-1/2 h-4 -translate-y-1/2"
: "inset-y-2 -left-0.5 bg-border/60",
)}
className="pointer-events-none absolute inset-y-2 -left-0.5 w-px bg-border/60"
/>
) : null}
<SidePanelTab
@@ -95,6 +91,7 @@ function SortableSidePanelTab({
appearance={appearance}
closable={tab.closable}
disabled={tab.disabled}
suppressTooltip={dragging}
tabRef={sortable.setActivatorNodeRef}
dragHandleProps={{
...sortable.attributes,
@@ -109,6 +106,43 @@ function SortableSidePanelTab({
);
}
function StreamlinedTabDragPreview({
tab,
active,
width,
labelIsTruncated,
}: {
tab: SidePanelTabItem;
active: boolean;
width: number;
labelIsTruncated: boolean;
}) {
return (
<div
aria-hidden
data-side-panel-tab-drag-preview={tab.id}
style={{ width }}
className={cn(
"relative flex h-7 min-w-0 items-center rounded-md border border-transparent text-sm font-medium text-foreground shadow-sm",
active ? "bg-(--side-panel-streamlined-tab-active-bg)" : "bg-(--side-panel-streamlined-tab-hover-bg)",
)}
>
<span className={cn(
"min-w-0 flex-auto overflow-hidden whitespace-nowrap pl-(--side-panel-streamlined-tab-padding-left)",
tab.closable === false ? "pr-1.5" : "pr-6",
labelIsTruncated && "side-panel-tab-label-fade",
)}>
{tab.label}
</span>
{tab.closable === false ? null : (
<span className="absolute right-0.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center text-muted-foreground">
<X className="size-3.5" />
</span>
)}
</div>
);
}
export interface SidePanelTabsProps {
tabs: SidePanelTabItem[];
activeTabId: string | null;
@@ -136,6 +170,8 @@ export function SidePanelTabs({
}: SidePanelTabsProps) {
const [announcement, setAnnouncement] = useState("");
const [showEndFade, setShowEndFade] = useState(false);
const [useWideTabs, setUseWideTabs] = useState(false);
const [draggedTab, setDraggedTab] = useState<{ id: string; width: number; labelIsTruncated: boolean } | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const addButtonRef = useRef<HTMLButtonElement>(null);
const addControlRef = useRef<HTMLDivElement>(null);
@@ -144,6 +180,29 @@ export function SidePanelTabs({
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
const draggedTabItem = draggedTab ? tabs.find((tab) => tab.id === draggedTab.id) : null;
useLayoutEffect(() => {
const element = scrollRef.current;
if (!element || appearance !== "streamlined-task") return;
const labels = Array.from(element.querySelectorAll<HTMLElement>("[data-side-panel-tab-label-content]"));
const updateSharedWidth = () => {
const compactWidth = parseFloat(getComputedStyle(element).getPropertyValue("--side-panel-streamlined-tab-min-width"));
// Measure intrinsic text against the compact width, regardless of the current
// row width. Using current truncation would oscillate between the two sizes.
setUseWideTabs(labels.some((content) => {
const label = content.parentElement!;
const wrapper = content.closest<HTMLElement>("[data-side-panel-tab-wrapper]")!;
const chromeWidth = wrapper.getBoundingClientRect().width - label.getBoundingClientRect().width;
return content.getBoundingClientRect().width + chromeWidth > compactWidth;
}));
};
updateSharedWidth();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateSharedWidth);
observer?.observe(element);
labels.forEach((label) => observer?.observe(label));
return () => observer?.disconnect();
}, [appearance, tabs]);
function findTabElement(tabId: string, selector: "wrapper" | "target") {
const attribute = selector === "wrapper"
@@ -249,6 +308,18 @@ export function SidePanelTabs({
setAnnouncement(`Moved ${tabs[from]?.label ?? "tab"} to position ${to + 1} of ${tabs.length}.`);
}
function handleDragStart(event: DragStartEvent) {
if (appearance !== "streamlined-task") return;
const id = String(event.active.id);
const wrapper = findTabElement(id, "wrapper");
if (!wrapper) return;
setDraggedTab({
id,
width: wrapper.getBoundingClientRect().width,
labelIsTruncated: wrapper.querySelector('[data-truncated="true"]') !== null,
});
}
return (
<div className={cn(
"flex min-w-0 flex-1 items-center",
@@ -259,6 +330,7 @@ export function SidePanelTabs({
ref={scrollRef}
role="tablist"
aria-orientation="horizontal"
data-tab-sizing={appearance === "streamlined-task" ? (useWideTabs ? "wide" : "compact") : undefined}
data-scroll-end-fade={appearance === "streamlined-task" && showEndFade ? "true" : undefined}
className={cn(
"side-panel-tabs-scroll min-w-0 flex-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
@@ -267,31 +339,37 @@ export function SidePanelTabs({
: "overflow-x-auto",
)}
>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={(event) => {
handleDragEnd(event);
setDraggedTab(null);
}}
onDragCancel={() => setDraggedTab(null)}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
<div className={cn(
"flex items-center",
appearance === "streamlined-task"
? tabs.length === 1
? "w-full gap-0"
: "w-max min-w-full gap-0"
? "w-full min-w-0 justify-start gap-0"
: "min-w-max gap-1 py-1",
)}>
)} style={appearance === "streamlined-task"
? { "--side-panel-streamlined-tab-count": Math.max(tabs.length, 1) } as CSSProperties
: undefined}>
{tabs.map((tab, index) => (
<SortableSidePanelTab
key={tab.id}
tab={tab}
active={tab.id === activeTabId}
dragging={draggedTab !== null}
appearance={appearance}
fillAvailableWidth={
appearance === "streamlined-task" && tabs.length === 1
}
showLeadingSeparator={
appearance === "streamlined-task"
? index > 0
: index > 0
&& tab.id !== activeTabId
&& tabs[index - 1]?.id !== activeTabId
appearance === "default"
&& index > 0
&& tab.id !== activeTabId
&& tabs[index - 1]?.id !== activeTabId
}
onSelect={() => onActiveTabChange(tab.id)}
onClose={() => closeTab(tab.id)}
@@ -305,6 +383,19 @@ export function SidePanelTabs({
))}
</div>
</SortableContext>
{appearance === "streamlined-task" && typeof document !== "undefined" ? createPortal(
<DragOverlay adjustScale={false} className="pointer-events-none z-20">
{draggedTab && draggedTabItem ? (
<StreamlinedTabDragPreview
tab={draggedTabItem}
active={draggedTab.id === activeTabId}
width={draggedTab.width}
labelIsTruncated={draggedTab.labelIsTruncated}
/>
) : null}
</DragOverlay>,
document.body,
) : null}
</DndContext>
</div>
{addControl ? <div ref={addControlRef} className="shrink-0">{addControl}</div> : (onAddTab ? (
@@ -440,9 +440,9 @@ describe("TaskSidePanel", () => {
const propertiesTab = container.querySelector<HTMLElement>('[data-side-panel-tab-target="properties"]');
expect(propertiesTab?.className).toContain("text-sm");
expect(propertiesTab?.querySelector("svg")).toBeNull();
expect(container.querySelector('[data-side-panel-tab-wrapper="properties"]')?.className).toContain("mx-1.5");
expect(container.querySelector('[data-side-panel-tab-wrapper="properties"]')?.parentElement?.className).toContain("mx-0.75");
expect(container.querySelector<HTMLButtonElement>('button[aria-label="Close Properties"]')?.className)
.toContain("opacity-0");
.toContain("opacity-100");
const addButton = container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]');
expect(addButton?.className).toContain("h-(--side-panel-tab-height)");
expect(addButton?.className).toContain("w-(--side-panel-tab-height)");
+31 -5
View File
@@ -468,8 +468,11 @@
--side-panel-tab-height: var(--sz-30px);
--side-panel-mobile-tabs-width: min(calc(100vw - var(--spacing) * 4), 24rem);
--side-panel-mobile-tabs-max-height: 60dvh;
--side-panel-streamlined-tab-min-width: var(--sz-72px);
--side-panel-streamlined-tab-max-width: var(--sz-160px);
--side-panel-streamlined-tab-min-width: var(--sz-100px);
--side-panel-streamlined-tab-max-width: 125px;
--side-panel-streamlined-tab-collapse-floor: var(--sz-48px);
--side-panel-streamlined-tab-inline-space: calc(var(--spacing) * 1.5);
--side-panel-streamlined-tab-padding-left: calc(var(--spacing) * 1.5 + var(--sz-4px));
--side-panel-tab-edge-fade-width: var(--sz-32px);
--side-panel-tab-label-max-width: var(--sz-100px);
--side-panel-tab-label-expanded-max-width: calc(var(--side-panel-tab-label-max-width) + var(--sz-18px));
@@ -483,6 +486,8 @@
--streamlined-task-detail-bg: var(--background);
--side-panel-tab-active-bg: color-mix(in oklab, var(--accent) 88%, var(--background));
--side-panel-tab-hover-bg: color-mix(in oklab, var(--accent) 58%, transparent);
--side-panel-streamlined-tab-hover-bg: oklch(0.9675 0 0);
--side-panel-streamlined-tab-active-bg: oklch(0.9581 0 0);
--radius-task-composer: 16px;
--tc-interaction-card-max-h: 50dvh;
--shadow-task-composer: 0 2px 8px -2px color-mix(in oklab, var(--foreground) 7%, transparent),
@@ -549,6 +554,8 @@
--side-panel-bg: var(--background);
--side-panel-fg: oklch(0.985 0 0);
--streamlined-task-detail-bg: var(--background);
--side-panel-streamlined-tab-hover-bg: var(--side-panel-tab-hover-bg);
--side-panel-streamlined-tab-active-bg: color-mix(in oklab, color-mix(in oklab, var(--accent) 92%, var(--background)) 97%, var(--foreground) 3%);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
@@ -1000,6 +1007,27 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size="
);
}
/* Keep every streamlined tab at its preferred width until the row must compress.
The usable floor leaves the close action accessible before horizontal scrolling. */
[data-tab-sizing="compact"] {
--side-panel-streamlined-tab-width: var(--side-panel-streamlined-tab-min-width);
}
[data-tab-sizing="wide"] {
--side-panel-streamlined-tab-width: var(--side-panel-streamlined-tab-max-width);
}
.side-panel-streamlined-tab {
min-width: var(--side-panel-streamlined-tab-collapse-floor);
width: max(
var(--side-panel-streamlined-tab-collapse-floor),
min(
var(--side-panel-streamlined-tab-width),
calc(100% / var(--side-panel-streamlined-tab-count) - var(--side-panel-streamlined-tab-inline-space))
)
);
}
/* Keep continuation tabs readable as they pass beneath the fixed add control.
The mask is enabled only while content remains beyond the right edge. */
.side-panel-tabs-scroll[data-scroll-end-fade="true"] {
@@ -1302,9 +1330,7 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size="
pointer-events: none;
}
.side-panel-tab-label-fade,
.side-panel-tab-label-close-fade[data-truncated="true"],
[data-appearance="streamlined-task"]:is(:hover, :focus-within) .side-panel-tab-label-close-fade {
.side-panel-tab-label-fade {
-webkit-mask-image: linear-gradient(to right, black calc(100% - var(--side-panel-tab-label-fade-width)), transparent);
mask-image: linear-gradient(to right, black calc(100% - var(--side-panel-tab-label-fade-width)), transparent);
-webkit-mask-repeat: no-repeat;
@@ -0,0 +1,74 @@
import { useRef, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SidePanelTabs, type SidePanelTabItem } from "@/components/side-panel";
const initialTabs: SidePanelTabItem[] = [
{ id: "properties", type: "properties", label: "Properties", closable: true },
{ id: "subtasks", type: "subtasks", label: "Subtasks", closable: true },
{ id: "notes", type: "issue-document", label: "ClipLab — Chat research notes", closable: true },
{ id: "plan", type: "issue-document", label: "Plan", closable: true },
{ id: "artifacts", type: "view", label: "Artifacts", closable: true },
{ id: "activity", type: "view", label: "Activity", closable: true },
{ id: "agent-runs", type: "view", label: "Agent runs", closable: true },
{ id: "related-tasks", type: "view", label: "Related tasks", closable: true },
{ id: "release-checklist", type: "issue-document", label: "Release checklist and handoff", closable: true },
];
function TaskSidePanelTabsPreview({ initialActive }: { initialActive: string }) {
const [tabs, setTabs] = useState(initialTabs);
const [activeTabId, setActiveTabId] = useState<string | null>(initialActive);
const nextTabNumber = useRef(1);
return (
<div className="w-full border border-border bg-background p-4">
<div className="flex h-(--side-panel-header-height) min-w-0 items-center border-b border-border">
<SidePanelTabs
tabs={tabs}
activeTabId={activeTabId}
onActiveTabChange={setActiveTabId}
onCloseTab={(tabId) => {
const remaining = tabs.filter((tab) => tab.id !== tabId);
setTabs(remaining);
if (activeTabId === tabId) setActiveTabId(remaining[0]?.id ?? null);
}}
onReorderTabs={(orderedIds) => {
setTabs(orderedIds.flatMap((id) => tabs.find((tab) => tab.id === id) ?? []));
}}
onAddTab={() => {
const number = nextTabNumber.current++;
const id = `new-tab-${number}`;
setTabs((current) => [...current, { id, type: "view", label: `New tab ${number}`, closable: true }]);
setActiveTabId(id);
}}
appearance="streamlined-task"
/>
</div>
</div>
);
}
const meta = {
id: "navigation-task-side-panel-tabs",
title: "Oversight and operation/Navigation, profile, and announcements/Product/Task side panel tabs",
component: TaskSidePanelTabsPreview,
parameters: {
docs: {
description: {
component: "Resize the preview to see nine starting tabs share the available width equally. All tabs use 100px when their labels fit, or 125px when any label needs more room, then shrink together as the pane narrows. Close the longer tabs to inspect compact sizing. Use the plus button to add tabs. Hover the tabs and close buttons to inspect the active fill, clipped-label fade, and round close hover.",
},
},
paperclip: {
capabilities: ["navigation-preferences/navigation"],
status: "current",
implementation: "production",
boundary: "fixtures",
notes: "Uses the shipped task side panel tab component with local tab state. Covers desktop tab sizing, selection, reordering, and closing; task data and persistence are outside this preview.",
},
},
} satisfies Meta<typeof TaskSidePanelTabsPreview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ShortTabActive: Story = { args: { initialActive: "subtasks" } };
export const LongTabActive: Story = { args: { initialActive: "notes" } };