From dba46ec673afae4d2f4b8895b4a47f94225ec625 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:16:22 +0000 Subject: [PATCH 1/9] Initial plan From 99bb4a6528e51a4528b1386b3c4f9b6e2997eefe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:24:58 +0000 Subject: [PATCH 2/9] feat: add vertical tab bar components and preview Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/tab/vtab.tsx | 83 ++++++++++++++ frontend/app/tab/vtabbar.tsx | 103 ++++++++++++++++++ frontend/preview/previews/vtabbar.preview.tsx | 63 +++++++++++ 3 files changed, 249 insertions(+) create mode 100644 frontend/app/tab/vtab.tsx create mode 100644 frontend/app/tab/vtabbar.tsx create mode 100644 frontend/preview/previews/vtabbar.preview.tsx diff --git a/frontend/app/tab/vtab.tsx b/frontend/app/tab/vtab.tsx new file mode 100644 index 0000000000..a2981b00cb --- /dev/null +++ b/frontend/app/tab/vtab.tsx @@ -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) => void; + onDragOver: (event: React.DragEvent) => void; + onDrop: (event: React.DragEvent) => void; + onDragEnd: () => void; +} + +export function VTab({ + tab, + active, + isDragging, + isDropTarget, + onSelect, + onClose, + onDragStart, + onDragOver, + onDrop, + onDragEnd, +}: VTabProps) { + return ( +
+ {tab.name} + {tab.indicator && ( + + + + )} + {onClose && ( + + )} +
+ ); +} diff --git a/frontend/app/tab/vtabbar.tsx b/frontend/app/tab/vtabbar.tsx new file mode 100644 index 0000000000..a47e6d99ff --- /dev/null +++ b/frontend/app/tab/vtabbar.tsx @@ -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(tabs); + const [dragTabId, setDragTabId] = useState(null); + const [dropTargetTabId, setDropTargetTabId] = useState(null); + const dragSourceRef = useRef(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 ( +
+
+ {orderedTabs.map((tab) => ( + 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} + /> + ))} +
+
+ ); +} diff --git a/frontend/preview/previews/vtabbar.preview.tsx b/frontend/preview/previews/vtabbar.preview.tsx new file mode 100644 index 0000000000..11841fae30 --- /dev/null +++ b/frontend/preview/previews/vtabbar.preview.tsx @@ -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(InitialTabs); + const [activeTabId, setActiveTabId] = useState(InitialTabs[0].id); + const [width, setWidth] = useState(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 ( +
+
+
Width: {width}px
+ setWidth(Number(event.target.value))} + className="w-full cursor-pointer" + /> +

+ Drag tabs to reorder. Names, indicators, and close buttons remain single-line. +

+
+
+ { + setTabs((prevTabs) => { + const tabById = new Map(prevTabs.map((tab) => [tab.id, tab])); + return tabIds.map((tabId) => tabById.get(tabId)).filter((tab) => tab != null); + }); + }} + /> +
+
+ ); +} From 189338b5251bbd3d1e7916215021fd61e4dc2415 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:08:35 +0000 Subject: [PATCH 3/9] fix: refine vtabbar drag drop indicators Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/tab/vtab.tsx | 23 ++++----- frontend/app/tab/vtabbar.tsx | 99 +++++++++++++++++++++++------------- 2 files changed, 74 insertions(+), 48 deletions(-) diff --git a/frontend/app/tab/vtab.tsx b/frontend/app/tab/vtab.tsx index a2981b00cb..3fdeaf63a8 100644 --- a/frontend/app/tab/vtab.tsx +++ b/frontend/app/tab/vtab.tsx @@ -4,22 +4,17 @@ 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; + indicator?: TabIndicator | null; } interface VTabProps { tab: VTabItem; active: boolean; isDragging: boolean; - isDropTarget: boolean; + isReordering: boolean; onSelect: () => void; onClose?: () => void; onDragStart: (event: React.DragEvent) => void; @@ -32,7 +27,7 @@ export function VTab({ tab, active, isDragging, - isDropTarget, + isReordering, onSelect, onClose, onDragStart, @@ -53,9 +48,10 @@ export function VTab({ "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" + : isReordering + ? "border-transparent bg-transparent text-secondary" + : "border-transparent bg-transparent text-secondary hover:border-border hover:bg-hover", + isDragging && "opacity-50" )} title={tab.name} > @@ -68,7 +64,10 @@ export function VTab({ {onClose && (