Skip to content

Commit 2cf8c26

Browse files
Copilotsawka
andauthored
Share tab badge rendering with vertical tabs (#3034)
Vertical tabs were still using the older single-badge path and did not honor the newer `tab:flagcolor` metadata. This updates `vtab` to use the same badge rendering model as `tab`, including validated flag-color badges. - **Shared badge renderer** - Extract `TabBadges` from `tab.tsx` into a shared `frontend/app/tab/tabbadges.tsx` component. - Keep the existing horizontal tab behavior unchanged while making the badge stack reusable by `vtab`. - **Vertical tab badge parity** - Replace the legacy `badge` icon rendering in `vtab.tsx` with `TabBadges`. - Support both existing `badge` callers and the newer `badges` array shape. - Add `flagColor` support in `vtab` using the same `validateCssColor(...)` guard as `tab.tsx`, so invalid colors are ignored rather than rendered. - **Preview / regression coverage** - Update the `vtabbar` preview to show: - multiple badges - flag-only tabs - mixed badge + flag tabs - Add focused `vtab` coverage for valid and invalid `flagColor` handling. - **Example** ```tsx const rawFlagColor = tab.flagColor; let flagColor: string | null = null; if (rawFlagColor) { try { validateCssColor(rawFlagColor); flagColor = rawFlagColor; } catch { flagColor = null; } } <TabBadges badges={badges} flagColor={flagColor} /> ``` - **Screenshot** - Updated vertical tab preview: https://github.com/user-attachments/assets/7d79930f-00cc-49a7-a0ec-d5554fb9e166 <!-- START COPILOT CODING AGENT TIPS --> --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
1 parent 60cdf05 commit 2cf8c26

File tree

5 files changed

+155
-54
lines changed

5 files changed

+155
-54
lines changed

frontend/app/tab/tab.tsx

Lines changed: 4 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
// Copyright 2026, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { getTabBadgeAtom, sortBadgesForTab } from "@/app/store/badge";
4+
import { getTabBadgeAtom } from "@/app/store/badge";
55
import { getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global";
66
import { TabRpcClient } from "@/app/store/wshrpcutil";
77
import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv";
88
import { Button } from "@/element/button";
99
import { validateCssColor } from "@/util/color-validator";
10-
import { fireAndForget, makeIconClass } from "@/util/util";
10+
import { fireAndForget } from "@/util/util";
1111
import clsx from "clsx";
1212
import { useAtomValue } from "jotai";
13-
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
14-
import { v7 as uuidv7 } from "uuid";
13+
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
1514
import { makeORef } from "../store/wos";
15+
import { TabBadges } from "./tabbadges";
1616
import "./tab.scss";
1717

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

50-
interface TabBadgesProps {
51-
badges?: Badge[] | null;
52-
flagColor?: string | null;
53-
}
54-
55-
function TabBadges({ badges, flagColor }: TabBadgesProps) {
56-
const flagBadgeId = useMemo(() => uuidv7(), []);
57-
const allBadges = useMemo(() => {
58-
const base = badges ?? [];
59-
if (!flagColor) {
60-
return base;
61-
}
62-
const flagBadge: Badge = { icon: "flag", color: flagColor, priority: 0, badgeid: flagBadgeId };
63-
return sortBadgesForTab([...base, flagBadge]);
64-
}, [badges, flagColor, flagBadgeId]);
65-
if (!allBadges[0]) {
66-
return null;
67-
}
68-
const firstBadge = allBadges[0];
69-
const extraBadges = allBadges.slice(1, 3);
70-
return (
71-
<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]">
72-
<i
73-
className={makeIconClass(firstBadge.icon, true, { defaultIcon: "circle-small" }) + " text-[12px]"}
74-
style={{ color: firstBadge.color || "#fbbf24" }}
75-
/>
76-
{extraBadges.length > 0 && (
77-
<div className="flex flex-col items-center justify-center gap-[2px] ml-[2px]">
78-
{extraBadges.map((badge, idx) => (
79-
<div
80-
key={idx}
81-
className="w-[4px] h-[4px] rounded-full"
82-
style={{ backgroundColor: badge.color || "#fbbf24" }}
83-
/>
84-
))}
85-
</div>
86-
)}
87-
</div>
88-
);
89-
}
90-
9150
const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
9251
const {
9352
tabId,

frontend/app/tab/tabbadges.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { sortBadgesForTab } from "@/app/store/badge";
5+
import { cn, makeIconClass } from "@/util/util";
6+
import { useMemo } from "react";
7+
import { v7 as uuidv7 } from "uuid";
8+
9+
export interface TabBadgesProps {
10+
badges?: Badge[] | null;
11+
flagColor?: string | null;
12+
className?: string;
13+
}
14+
15+
const DefaultClassName =
16+
"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]";
17+
18+
export function TabBadges({ badges, flagColor, className }: TabBadgesProps) {
19+
const flagBadgeId = useMemo(() => uuidv7(), []);
20+
const allBadges = useMemo(() => {
21+
const base = badges ?? [];
22+
if (!flagColor) {
23+
return base;
24+
}
25+
const flagBadge: Badge = { icon: "flag", color: flagColor, priority: 0, badgeid: flagBadgeId };
26+
return sortBadgesForTab([...base, flagBadge]);
27+
}, [badges, flagColor, flagBadgeId]);
28+
if (!allBadges[0]) {
29+
return null;
30+
}
31+
const firstBadge = allBadges[0];
32+
const extraBadges = allBadges.slice(1, 3);
33+
return (
34+
<div className={cn(DefaultClassName, className)}>
35+
<i
36+
className={makeIconClass(firstBadge.icon, true, { defaultIcon: "circle-small" }) + " text-[12px]"}
37+
style={{ color: firstBadge.color || "#fbbf24" }}
38+
/>
39+
{extraBadges.length > 0 && (
40+
<div className="ml-[2px] flex flex-col items-center justify-center gap-[2px]">
41+
{extraBadges.map((badge, idx) => (
42+
<div
43+
key={idx}
44+
className="h-[4px] w-[4px] rounded-full"
45+
style={{ backgroundColor: badge.color || "#fbbf24" }}
46+
/>
47+
))}
48+
</div>
49+
)}
50+
</div>
51+
);
52+
}

frontend/app/tab/vtab.test.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { renderToStaticMarkup } from "react-dom/server";
5+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
6+
import { VTab, VTabItem } from "./vtab";
7+
8+
const OriginalCss = globalThis.CSS;
9+
const HexColorRegex = /^#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i;
10+
11+
function renderVTab(tab: VTabItem): string {
12+
return renderToStaticMarkup(
13+
<VTab
14+
tab={tab}
15+
active={false}
16+
isDragging={false}
17+
isReordering={false}
18+
onSelect={() => null}
19+
onDragStart={() => null}
20+
onDragOver={() => null}
21+
onDrop={() => null}
22+
onDragEnd={() => null}
23+
/>
24+
);
25+
}
26+
27+
describe("VTab badges", () => {
28+
beforeAll(() => {
29+
globalThis.CSS = {
30+
supports: (_property: string, value: string) => HexColorRegex.test(value),
31+
} as typeof CSS;
32+
});
33+
34+
afterAll(() => {
35+
globalThis.CSS = OriginalCss;
36+
});
37+
38+
it("renders shared badges and a validated flag badge", () => {
39+
const markup = renderVTab({
40+
id: "tab-1",
41+
name: "Build Logs",
42+
badges: [{ badgeid: "badge-1", icon: "bell", color: "#f59e0b", priority: 2 }],
43+
flagColor: "#429DFF",
44+
});
45+
46+
expect(markup).toContain("#429DFF");
47+
expect(markup).toContain("#f59e0b");
48+
expect(markup).toContain("rounded-full");
49+
});
50+
51+
it("ignores invalid flag colors", () => {
52+
const markup = renderVTab({
53+
id: "tab-2",
54+
name: "Deploy",
55+
badges: [{ badgeid: "badge-2", icon: "bell", color: "#4ade80", priority: 2 }],
56+
flagColor: "definitely-not-a-color",
57+
});
58+
59+
expect(markup).not.toContain("definitely-not-a-color");
60+
expect(markup).not.toContain("fa-flag");
61+
expect(markup).toContain("#4ade80");
62+
});
63+
});

frontend/app/tab/vtab.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
// Copyright 2026, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

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

89
const RenameFocusDelayMs = 50;
910

1011
export interface VTabItem {
1112
id: string;
1213
name: string;
1314
badge?: Badge | null;
15+
badges?: Badge[] | null;
16+
flagColor?: string | null;
1417
}
1518

1619
interface VTabProps {
@@ -44,6 +47,18 @@ export function VTab({
4447
const [isEditable, setIsEditable] = useState(false);
4548
const editableRef = useRef<HTMLDivElement>(null);
4649
const editableTimeoutRef = useRef<NodeJS.Timeout | null>(null);
50+
const badges = tab.badges ?? (tab.badge ? [tab.badge] : null);
51+
52+
const rawFlagColor = tab.flagColor;
53+
let flagColor: string | null = null;
54+
if (rawFlagColor) {
55+
try {
56+
validateCssColor(rawFlagColor);
57+
flagColor = rawFlagColor;
58+
} catch {
59+
flagColor = null;
60+
}
61+
}
4762

4863
useEffect(() => {
4964
setOriginalName(tab.name);
@@ -139,11 +154,11 @@ export function VTab({
139154
isDragging && "opacity-50"
140155
)}
141156
>
142-
{tab.badge && (
143-
<span className="mr-1 shrink-0 text-xs" style={{ color: tab.badge.color || "#fbbf24" }}>
144-
<i className={makeIconClass(tab.badge.icon, true, { defaultIcon: "circle-small" })} />
145-
</span>
146-
)}
157+
<TabBadges
158+
badges={badges}
159+
flagColor={flagColor}
160+
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]"
161+
/>
147162
<div
148163
ref={editableRef}
149164
className={cn(

frontend/preview/previews/vtabbar.preview.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,22 @@ import { useState } from "react";
66

77
const InitialTabs: VTabItem[] = [
88
{ id: "vtab-1", name: "Terminal" },
9-
{ id: "vtab-2", name: "Build Logs", badge: { badgeid: "01957000-0000-7000-0000-000000000001", icon: "bell", color: "#f59e0b", priority: 1 } },
10-
{ id: "vtab-3", name: "Deploy" },
9+
{
10+
id: "vtab-2",
11+
name: "Build Logs",
12+
badges: [
13+
{ badgeid: "01957000-0000-7000-0000-000000000001", icon: "bell", color: "#f59e0b", priority: 2 },
14+
{ badgeid: "01957000-0000-7000-0000-000000000002", icon: "circle-small", color: "#4ade80", priority: 3 },
15+
],
16+
},
17+
{ id: "vtab-3", name: "Deploy", flagColor: "#429DFF" },
1118
{ id: "vtab-4", name: "Wave AI" },
12-
{ id: "vtab-5", name: "A Very Long Tab Name To Show Truncation" },
19+
{
20+
id: "vtab-5",
21+
name: "A Very Long Tab Name To Show Truncation",
22+
badges: [{ badgeid: "01957000-0000-7000-0000-000000000003", icon: "solid@terminal", color: "#fbbf24", priority: 3 }],
23+
flagColor: "#BF55EC",
24+
},
1325
];
1426

1527
export function VTabBarPreview() {

0 commit comments

Comments
 (0)