style(config): give Codex the same tab bar as Claude Code, drop the Cursor tab

The Codex tab strip wrapped onto a second row while Claude Code's stayed
one scrollable row, so two panels that are meant to be twins read as
different components. Both now render the same ConfigTabBar — the
scroller lifted out of CcConfig, with the fade edges and chevrons that
only appear when there is somewhere to scroll. The deferral note in
CodexConfig that predicted this is gone with it.

The bar also carries role="tablist"/"tab" and aria-selected now: Codex
had them, Claude Code did not, and one of the two had to win.

Cursor's placeholder tab goes away with the machinery that served only
it — the optional Panel, the "soon" badge, the coming-soon card, and
their four i18n keys.

Element.scrollBy and friends move to test-setup: jsdom has none of them,
and the tab bar now calls them from both pages.
This commit is contained in:
2026-09-09 14:44:25 +07:00
parent 742e23e418
commit 2942ced3ec
13 changed files with 233 additions and 199 deletions
+13 -118
View File
@@ -90,8 +90,6 @@ import {
Keyboard,
CircleDot,
CircleSlash,
ChevronLeft,
ChevronRight,
ChevronDown,
UserCog,
} from "lucide-react";
@@ -105,6 +103,7 @@ import {
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { MdItemList } from "../components/config/MdItemList";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import {
Empty,
@@ -747,53 +746,6 @@ interface TabsProps {
function Tabs({ current, onSelect, counts }: TabsProps) {
const { t } = useTranslation("ccConfig");
const scrollRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Update scroll affordances when content size or scroll position changes.
const updateAffordances = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 1);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
}, []);
useEffect(() => {
updateAffordances();
const el = scrollRef.current;
if (!el) return;
el.addEventListener("scroll", updateAffordances, { passive: true });
const ro = new ResizeObserver(updateAffordances);
ro.observe(el);
return () => {
el.removeEventListener("scroll", updateAffordances);
ro.disconnect();
};
}, [updateAffordances]);
// Scroll the active tab into view when it changes (e.g. user picks a tab
// that's offscreen, or window resize hides the active one).
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const active = el.querySelector<HTMLElement>('[data-tab-active="true"]');
if (!active) return;
const elRect = el.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
if (activeRect.left < elRect.left + 8) {
el.scrollBy({ left: activeRect.left - elRect.left - 16, behavior: "smooth" });
} else if (activeRect.right > elRect.right - 8) {
el.scrollBy({ left: activeRect.right - elRect.right + 16, behavior: "smooth" });
}
}, [current]);
const scrollByButton = (dir: 1 | -1) => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: dir * Math.max(200, el.clientWidth * 0.6), behavior: "smooth" });
};
const countFor = (key: TabKey): number | null => {
if (!counts) return null;
switch (key) {
@@ -824,75 +776,18 @@ function Tabs({ current, onSelect, counts }: TabsProps) {
}
};
return (
<div className="relative rounded-xl border border-border bg-surface-1">
{/* Left edge gradient + chevron */}
<div
className={`pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 ${
canScrollLeft ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollLeft && (
<button
onClick={() => scrollByButton(-1)}
aria-label="scroll tabs left"
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<div
ref={scrollRef}
className="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: "none" }}
>
{TABS.map(({ key, icon: Icon, i18nKey }) => {
const c = countFor(key);
const active = current === key;
return (
<button
key={key}
data-testid={`cc-tab-${key}`}
data-tab-active={active ? "true" : undefined}
onClick={() => onSelect(key)}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
active
? "bg-accent/15 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`}
>
<Icon className="w-3.5 h-3.5" />
<span>{t(i18nKey)}</span>
{c !== null && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-semibold ${
active ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-secondary"
}`}
>
{c}
</span>
)}
</button>
);
})}
</div>
{/* Right edge gradient + chevron */}
<div
className={`pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 ${
canScrollRight ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollRight && (
<button
onClick={() => scrollByButton(1)}
aria-label="scroll tabs right"
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
<ConfigTabBar
tabs={TABS.map(({ key, icon, i18nKey }) => ({
key,
icon,
label: t(i18nKey),
count: countFor(key),
}))}
current={current}
onSelect={onSelect}
testIdPrefix="cc-tab"
ariaLabel={t("title")}
/>
);
}
+13 -37
View File
@@ -49,6 +49,7 @@ import {
} from "../components/config/ConfirmDeleteModal";
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import {
Empty,
@@ -82,9 +83,6 @@ interface TabDef {
i18nKey: string;
}
// ponytail: no horizontal-scroll chevrons for the tab bar - 8 tabs wrap
// cleanly at common widths, unlike CcConfig's 12. Add the scroll affordance
// from CcConfig's Tabs() if a future tab count needs it.
const TABS: TabDef[] = [
{ key: "overview", icon: Boxes, i18nKey: "tabs.overview" },
{ key: "skills", icon: Sparkles, i18nKey: "tabs.skills" },
@@ -625,40 +623,18 @@ function Tabs({
}
};
return (
<div className="rounded-xl border border-border bg-surface-1">
<div className="flex flex-wrap gap-1 p-1">
{TABS.map(({ key, icon: Icon, i18nKey }) => {
const c = countFor(key);
const active = current === key;
return (
<button
key={key}
data-testid={`codex-tab-${key}`}
role="tab"
aria-selected={active}
onClick={() => onSelect(key)}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
active
? "bg-accent/15 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`}
>
<Icon className="w-3.5 h-3.5" />
<span>{t(i18nKey)}</span>
{c !== null && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-semibold ${
active ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-secondary"
}`}
>
{c}
</span>
)}
</button>
);
})}
</div>
</div>
<ConfigTabBar
tabs={TABS.map(({ key, icon, i18nKey }) => ({
key,
icon,
label: t(i18nKey),
count: countFor(key),
}))}
current={current}
onSelect={onSelect}
testIdPrefix="codex-tab"
ariaLabel={t("title")}
/>
);
}
+5 -17
View File
@@ -1,12 +1,12 @@
/**
* @file SettingsHub.tsx
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus per-AI-tool configuration panels (Claude Code and Codex today, Cursor reserved), with the active tab kept in the `?tool=` query param.
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex), with the active tab kept in the `?tool=` query param.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Boxes, Settings as SettingsIcon, SquareTerminal, MousePointerClick } from "lucide-react";
import { Boxes, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
import { PageTabs } from "../components/PageTabs";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
@@ -16,8 +16,7 @@ interface ToolTab {
id: string;
labelKey: string;
Icon: ComponentType<{ className?: string }>;
/** Omit for tools that are not integrated yet - the tab renders a placeholder. */
Panel?: ComponentType;
Panel: ComponentType;
}
const DASHBOARD_TAB: ToolTab = {
@@ -32,7 +31,6 @@ const TOOL_TABS: ToolTab[] = [
DASHBOARD_TAB,
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
{ id: "cursor", labelKey: "tools.cursor", Icon: MousePointerClick },
];
/**
@@ -51,24 +49,14 @@ export function SettingsHub() {
ariaLabel={t("tools.label", "Tools")}
activeId={active.id}
onSelect={(id) => setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })}
tabs={TOOL_TABS.map(({ id, labelKey, Icon, Panel }) => ({
tabs={TOOL_TABS.map(({ id, labelKey, Icon }) => ({
id,
Icon,
label: t(labelKey),
...(Panel ? {} : { badge: t("tools.soonBadge", "soon") }),
}))}
/>
{active.Panel ? (
<active.Panel />
) : (
<div className="card p-8 text-center space-y-1">
<p className="text-sm font-medium text-fg-primary">
{t("tools.comingSoon", { tool: t(active.labelKey) })}
</p>
<p className="text-xs text-fg-muted">{t("tools.comingSoonDesc")}</p>
</div>
)}
<active.Panel />
</div>
);
}
@@ -80,11 +80,6 @@ class ObserverStub {
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
async function settle() {
await act(async () => {
@@ -936,13 +936,17 @@ exports[`screen snapshots > Claude Config 1`] = `
class="pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 opacity-0"
/>
<div
aria-label="Claude Config Explorer"
class="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
role="tablist"
style="scrollbar-width: none;"
>
<button
aria-selected="true"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap bg-accent/15 text-accent border border-accent/30"
data-tab-active="true"
data-testid="cc-tab-overview"
role="tab"
>
<svg
class="lucide lucide-boxes w-3.5 h-3.5"
@@ -998,8 +1002,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-skills"
role="tab"
>
<svg
class="lucide lucide-sparkles w-3.5 h-3.5"
@@ -1039,8 +1045,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-agents"
role="tab"
>
<svg
class="lucide lucide-user-round w-3.5 h-3.5"
@@ -1073,8 +1081,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-commands"
role="tab"
>
<svg
class="lucide lucide-slash w-3.5 h-3.5"
@@ -1102,8 +1112,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-memory"
role="tab"
>
<svg
class="lucide lucide-book-open w-3.5 h-3.5"
@@ -1134,8 +1146,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-plugins"
role="tab"
>
<svg
class="lucide lucide-plug-zap w-3.5 h-3.5"
@@ -1175,8 +1189,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-marketplaces"
role="tab"
>
<svg
class="lucide lucide-store w-3.5 h-3.5"
@@ -1216,8 +1232,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-mcp"
role="tab"
>
<svg
class="lucide lucide-server w-3.5 h-3.5"
@@ -1270,8 +1288,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-hooks"
role="tab"
>
<svg
class="lucide lucide-webhook w-3.5 h-3.5"
@@ -1305,8 +1325,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-keybindings"
role="tab"
>
<svg
class="lucide lucide-keyboard w-3.5 h-3.5"
@@ -1362,8 +1384,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-settings"
role="tab"
>
<svg
class="lucide lucide-settings w-3.5 h-3.5"
@@ -1396,8 +1420,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-outputStyles"
role="tab"
>
<svg
class="lucide lucide-palette w-3.5 h-3.5"
@@ -9511,7 +9537,7 @@ exports[`screen snapshots > Settings 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v1.4.6 (rebuild client to match)
UI build v1.5.0 (rebuild client to match)
</p>
</div>
<div
@@ -521,11 +521,6 @@ if (!window.matchMedia) {
},
}) as unknown as MediaQueryList;
}
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
// Flush pending promises (mocked API resolves) + effects so the loaded state
// is rendered before snapshotting.