Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat: add vertical tab bar components and preview
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
  • Loading branch information
Copilot and sawka committed Mar 2, 2026
commit 99bb4a6528e51a4528b1386b3c4f9b6e2997eefe
83 changes: 83 additions & 0 deletions frontend/app/tab/vtab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { makeIconClass } from "@/util/util";
import { cn } from "@/util/util";

export interface VTabIndicator {
icon: string;
color?: string;
}

export interface VTabItem {
id: string;
name: string;
indicator?: VTabIndicator | null;
}

interface VTabProps {
tab: VTabItem;
active: boolean;
isDragging: boolean;
isDropTarget: boolean;
onSelect: () => void;
onClose?: () => void;
onDragStart: (event: React.DragEvent<HTMLDivElement>) => void;
onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
onDragEnd: () => void;
}

export function VTab({
tab,
active,
isDragging,
isDropTarget,
onSelect,
onClose,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
}: VTabProps) {
return (
<div
draggable
onClick={onSelect}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
className={cn(
"group flex h-9 w-full cursor-pointer items-center gap-2 rounded-md border px-2 text-sm transition-colors select-none",
"whitespace-nowrap",
active
? "border-accent/40 bg-accent/20 text-primary"
: "border-transparent bg-transparent text-secondary hover:border-border hover:bg-hover",
isDragging && "opacity-50",
isDropTarget && "border-accent/70"
)}
title={tab.name}
>
<span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{tab.name}</span>
{tab.indicator && (
<span className="shrink-0 text-xs" style={{ color: tab.indicator.color || "#fbbf24" }}>
<i className={makeIconClass(tab.indicator.icon, true, { defaultIcon: "bell" })} />
</span>
)}
{onClose && (
<button
type="button"
className="shrink-0 cursor-pointer rounded p-1 text-secondary opacity-0 transition group-hover:opacity-100 hover:bg-hoverbg hover:text-primary"
onClick={(event) => {
event.stopPropagation();
onClose();
}}
aria-label="Close tab"
>
<i className="fa fa-solid fa-xmark" />
</button>
)}
</div>
);
}
103 changes: 103 additions & 0 deletions frontend/app/tab/vtabbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { cn } from "@/util/util";
import { useEffect, useMemo, useRef, useState } from "react";
import { VTab, VTabItem } from "./vtab";
export type { VTabItem } from "./vtab";

interface VTabBarProps {
tabs: VTabItem[];
activeTabId?: string;
width?: number;
className?: string;
onSelectTab?: (tabId: string) => void;
onCloseTab?: (tabId: string) => void;
onReorderTabs?: (tabIds: string[]) => void;
}

function clampWidth(width?: number): number {
if (width == null) {
return 220;
}
if (width < 100) {
return 100;
}
if (width > 400) {
return 400;
}
return width;
}

export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCloseTab, onReorderTabs }: VTabBarProps) {
const [orderedTabs, setOrderedTabs] = useState<VTabItem[]>(tabs);
const [dragTabId, setDragTabId] = useState<string>(null);
const [dropTargetTabId, setDropTargetTabId] = useState<string>(null);
const dragSourceRef = useRef<string>(null);

useEffect(() => {
setOrderedTabs(tabs);
}, [tabs]);

const barWidth = useMemo(() => clampWidth(width), [width]);

const clearDragState = () => {
dragSourceRef.current = null;
setDragTabId(null);
setDropTargetTabId(null);
};

const reorder = (targetTabId: string) => {
const sourceTabId = dragSourceRef.current;
if (sourceTabId == null || sourceTabId === targetTabId) {
return;
}
const sourceIndex = orderedTabs.findIndex((tab) => tab.id === sourceTabId);
const targetIndex = orderedTabs.findIndex((tab) => tab.id === targetTabId);
if (sourceIndex === -1 || targetIndex === -1) {
return;
}
const nextTabs = [...orderedTabs];
const [movedTab] = nextTabs.splice(sourceIndex, 1);
nextTabs.splice(targetIndex, 0, movedTab);
setOrderedTabs(nextTabs);
onReorderTabs?.(nextTabs.map((tab) => tab.id));
};

return (
<div
className={cn("flex h-full min-w-[100px] max-w-[400px] flex-col overflow-hidden border-r border-border bg-panel", className)}
style={{ width: barWidth }}
>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-1">
{orderedTabs.map((tab) => (
<VTab
key={tab.id}
tab={tab}
active={tab.id === activeTabId}
isDragging={dragTabId === tab.id}
isDropTarget={dropTargetTabId === tab.id && dragTabId !== tab.id}
onSelect={() => onSelectTab?.(tab.id)}
onClose={onCloseTab ? () => onCloseTab(tab.id) : undefined}
onDragStart={(event) => {
dragSourceRef.current = tab.id;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", tab.id);
setDragTabId(tab.id);
}}
onDragOver={(event) => {
event.preventDefault();
setDropTargetTabId(tab.id);
}}
onDrop={(event) => {
event.preventDefault();
reorder(tab.id);
clearDragState();
}}
onDragEnd={clearDragState}
/>
))}
</div>
</div>
);
}
63 changes: 63 additions & 0 deletions frontend/preview/previews/vtabbar.preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { VTabBar, VTabItem } from "@/app/tab/vtabbar";
import { useState } from "react";

const InitialTabs: VTabItem[] = [
{ id: "vtab-1", name: "Terminal" },
{ id: "vtab-2", name: "Build Logs", indicator: { icon: "bell", color: "#f59e0b" } },
{ id: "vtab-3", name: "Deploy" },
{ id: "vtab-4", name: "Wave AI" },
{ id: "vtab-5", name: "A Very Long Tab Name To Show Truncation" },
];

export function VTabBarPreview() {
const [tabs, setTabs] = useState<VTabItem[]>(InitialTabs);
const [activeTabId, setActiveTabId] = useState<string>(InitialTabs[0].id);
const [width, setWidth] = useState<number>(220);

const handleCloseTab = (tabId: string) => {
setTabs((prevTabs) => {
const nextTabs = prevTabs.filter((tab) => tab.id !== tabId);
if (activeTabId === tabId && nextTabs.length > 0) {
setActiveTabId(nextTabs[0].id);
}
return nextTabs;
});
};

return (
<div className="flex w-full max-w-[900px] gap-6 px-6">
<div className="w-[300px] shrink-0 rounded-md border border-border bg-panel p-4">
<div className="mb-3 text-xs text-muted">Width: {width}px</div>
<input
type="range"
min={100}
max={400}
value={width}
onChange={(event) => setWidth(Number(event.target.value))}
className="w-full cursor-pointer"
/>
<p className="mt-3 text-xs text-muted">
Drag tabs to reorder. Names, indicators, and close buttons remain single-line.
</p>
</div>
<div className="h-[360px] rounded-md border border-border bg-background">
<VTabBar
tabs={tabs}
activeTabId={activeTabId}
width={width}
onSelectTab={setActiveTabId}
onCloseTab={handleCloseTab}
onReorderTabs={(tabIds) => {
setTabs((prevTabs) => {
const tabById = new Map(prevTabs.map((tab) => [tab.id, tab]));
return tabIds.map((tabId) => tabById.get(tabId)).filter((tab) => tab != null);
});
}}
/>
</div>
</div>
);
}