Skip to content
Merged
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
49 changes: 4 additions & 45 deletions frontend/app/tab/tab.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { getTabBadgeAtom, sortBadgesForTab } from "@/app/store/badge";
import { getTabBadgeAtom } from "@/app/store/badge";
import { getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv";
import { Button } from "@/element/button";
import { validateCssColor } from "@/util/color-validator";
import { fireAndForget, makeIconClass } from "@/util/util";
import { fireAndForget } from "@/util/util";
import clsx from "clsx";
import { useAtomValue } from "jotai";
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { v7 as uuidv7 } from "uuid";
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { makeORef } from "../store/wos";
import { TabBadges } from "./tabbadges";
import "./tab.scss";

type TabEnv = WaveEnvSubset<{
Expand Down Expand Up @@ -47,47 +47,6 @@ interface TabVProps {
renameRef?: React.RefObject<(() => void) | null>;
}

interface TabBadgesProps {
badges?: Badge[] | null;
flagColor?: string | null;
}

function TabBadges({ badges, flagColor }: TabBadgesProps) {
const flagBadgeId = useMemo(() => uuidv7(), []);
const allBadges = useMemo(() => {
const base = badges ?? [];
if (!flagColor) {
return base;
}
const flagBadge: Badge = { icon: "flag", color: flagColor, priority: 0, badgeid: flagBadgeId };
return sortBadgesForTab([...base, flagBadge]);
}, [badges, flagColor, flagBadgeId]);
if (!allBadges[0]) {
return null;
}
const firstBadge = allBadges[0];
const extraBadges = allBadges.slice(1, 3);
return (
<div className="pointer-events-none absolute left-[4px] top-1/2 z-[3] flex h-[20px] w-[20px] -translate-y-1/2 items-center justify-center px-[2px] py-[1px]">
<i
className={makeIconClass(firstBadge.icon, true, { defaultIcon: "circle-small" }) + " text-[12px]"}
style={{ color: firstBadge.color || "#fbbf24" }}
/>
{extraBadges.length > 0 && (
<div className="flex flex-col items-center justify-center gap-[2px] ml-[2px]">
{extraBadges.map((badge, idx) => (
<div
key={idx}
className="w-[4px] h-[4px] rounded-full"
style={{ backgroundColor: badge.color || "#fbbf24" }}
/>
))}
</div>
)}
</div>
);
}

const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
const {
tabId,
Expand Down
52 changes: 52 additions & 0 deletions frontend/app/tab/tabbadges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { sortBadgesForTab } from "@/app/store/badge";
import { cn, makeIconClass } from "@/util/util";
import { useMemo } from "react";
import { v7 as uuidv7 } from "uuid";

export interface TabBadgesProps {
badges?: Badge[] | null;
flagColor?: string | null;
className?: string;
}

const DefaultClassName =
"pointer-events-none absolute left-[4px] top-1/2 z-[3] flex h-[20px] w-[20px] -translate-y-1/2 items-center justify-center px-[2px] py-[1px]";

export function TabBadges({ badges, flagColor, className }: TabBadgesProps) {
const flagBadgeId = useMemo(() => uuidv7(), []);
const allBadges = useMemo(() => {
const base = badges ?? [];
if (!flagColor) {
return base;
}
const flagBadge: Badge = { icon: "flag", color: flagColor, priority: 0, badgeid: flagBadgeId };
return sortBadgesForTab([...base, flagBadge]);
}, [badges, flagColor, flagBadgeId]);
if (!allBadges[0]) {
return null;
}
const firstBadge = allBadges[0];
const extraBadges = allBadges.slice(1, 3);
return (
<div className={cn(DefaultClassName, className)}>
<i
className={makeIconClass(firstBadge.icon, true, { defaultIcon: "circle-small" }) + " text-[12px]"}
style={{ color: firstBadge.color || "#fbbf24" }}
/>
{extraBadges.length > 0 && (
<div className="ml-[2px] flex flex-col items-center justify-center gap-[2px]">
{extraBadges.map((badge, idx) => (
<div
key={idx}
className="h-[4px] w-[4px] rounded-full"
style={{ backgroundColor: badge.color || "#fbbf24" }}
/>
))}
</div>
)}
</div>
);
}
63 changes: 63 additions & 0 deletions frontend/app/tab/vtab.test.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 { renderToStaticMarkup } from "react-dom/server";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { VTab, VTabItem } from "./vtab";

const OriginalCss = globalThis.CSS;
const HexColorRegex = /^#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i;

function renderVTab(tab: VTabItem): string {
return renderToStaticMarkup(
<VTab
tab={tab}
active={false}
isDragging={false}
isReordering={false}
onSelect={() => null}
onDragStart={() => null}
onDragOver={() => null}
onDrop={() => null}
onDragEnd={() => null}
/>
);
}

describe("VTab badges", () => {
beforeAll(() => {
globalThis.CSS = {
supports: (_property: string, value: string) => HexColorRegex.test(value),
} as typeof CSS;
});

afterAll(() => {
globalThis.CSS = OriginalCss;
});

it("renders shared badges and a validated flag badge", () => {
const markup = renderVTab({
id: "tab-1",
name: "Build Logs",
badges: [{ badgeid: "badge-1", icon: "bell", color: "#f59e0b", priority: 2 }],
flagColor: "#429DFF",
});

expect(markup).toContain("#429DFF");
expect(markup).toContain("#f59e0b");
expect(markup).toContain("rounded-full");
});

it("ignores invalid flag colors", () => {
const markup = renderVTab({
id: "tab-2",
name: "Deploy",
badges: [{ badgeid: "badge-2", icon: "bell", color: "#4ade80", priority: 2 }],
flagColor: "definitely-not-a-color",
});

expect(markup).not.toContain("definitely-not-a-color");
expect(markup).not.toContain("fa-flag");
expect(markup).toContain("#4ade80");
});
});
27 changes: 21 additions & 6 deletions frontend/app/tab/vtab.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { makeIconClass } from "@/util/util";
import { validateCssColor } from "@/util/color-validator";
import { cn } from "@/util/util";
import { useCallback, useEffect, useRef, useState } from "react";
import { TabBadges } from "./tabbadges";

const RenameFocusDelayMs = 50;

export interface VTabItem {
id: string;
name: string;
badge?: Badge | null;
badges?: Badge[] | null;
flagColor?: string | null;
}

interface VTabProps {
Expand Down Expand Up @@ -44,6 +47,18 @@ export function VTab({
const [isEditable, setIsEditable] = useState(false);
const editableRef = useRef<HTMLDivElement>(null);
const editableTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const badges = tab.badges ?? (tab.badge ? [tab.badge] : null);

const rawFlagColor = tab.flagColor;
let flagColor: string | null = null;
if (rawFlagColor) {
try {
validateCssColor(rawFlagColor);
flagColor = rawFlagColor;
} catch {
flagColor = null;
}
}

useEffect(() => {
setOriginalName(tab.name);
Expand Down Expand Up @@ -139,11 +154,11 @@ export function VTab({
isDragging && "opacity-50"
)}
>
{tab.badge && (
<span className="mr-1 shrink-0 text-xs" style={{ color: tab.badge.color || "#fbbf24" }}>
<i className={makeIconClass(tab.badge.icon, true, { defaultIcon: "circle-small" })} />
</span>
)}
<TabBadges
badges={badges}
flagColor={flagColor}
className="mr-1 min-w-[20px] shrink-0 static top-auto left-auto z-auto h-[20px] w-auto translate-y-0 justify-start px-[2px] py-[1px]"
/>
<div
ref={editableRef}
className={cn(
Expand Down
18 changes: 15 additions & 3 deletions frontend/preview/previews/vtabbar.preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,22 @@ import { useState } from "react";

const InitialTabs: VTabItem[] = [
{ id: "vtab-1", name: "Terminal" },
{ id: "vtab-2", name: "Build Logs", badge: { badgeid: "01957000-0000-7000-0000-000000000001", icon: "bell", color: "#f59e0b", priority: 1 } },
{ id: "vtab-3", name: "Deploy" },
{
id: "vtab-2",
name: "Build Logs",
badges: [
{ badgeid: "01957000-0000-7000-0000-000000000001", icon: "bell", color: "#f59e0b", priority: 2 },
{ badgeid: "01957000-0000-7000-0000-000000000002", icon: "circle-small", color: "#4ade80", priority: 3 },
],
},
{ id: "vtab-3", name: "Deploy", flagColor: "#429DFF" },
{ id: "vtab-4", name: "Wave AI" },
{ id: "vtab-5", name: "A Very Long Tab Name To Show Truncation" },
{
id: "vtab-5",
name: "A Very Long Tab Name To Show Truncation",
badges: [{ badgeid: "01957000-0000-7000-0000-000000000003", icon: "solid@terminal", color: "#fbbf24", priority: 3 }],
flagColor: "#BF55EC",
},
];

export function VTabBarPreview() {
Expand Down
Loading