From 2942ced3ecb54a5e115180ae360f41e13206bda9 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 9 Sep 2026 14:44:25 +0700 Subject: [PATCH] style(config): give Codex the same tab bar as Claude Code, drop the Cursor tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/components/PageTabs.tsx | 5 +- client/src/components/config/ConfigTabBar.tsx | 163 ++++++++++++++++++ client/src/i18n/locales/en/settings.json | 6 +- client/src/i18n/locales/vi/settings.json | 6 +- client/src/pages/CcConfig.tsx | 131 ++------------ client/src/pages/CodexConfig.tsx | 50 ++---- client/src/pages/SettingsHub.tsx | 22 +-- .../pages/__tests__/Run.defaultCwd.test.tsx | 5 - .../screens.snapshot.test.tsx.snap | 28 ++- .../pages/__tests__/screens.snapshot.test.tsx | 5 - client/src/test-setup.ts | 7 + docs/CLI.md | 2 +- server/openapi.js | 2 +- 13 files changed, 233 insertions(+), 199 deletions(-) create mode 100644 client/src/components/config/ConfigTabBar.tsx diff --git a/client/src/components/PageTabs.tsx b/client/src/components/PageTabs.tsx index 968aee0f..3a7c7d68 100644 --- a/client/src/components/PageTabs.tsx +++ b/client/src/components/PageTabs.tsx @@ -11,8 +11,6 @@ export interface PageTab { id: string; label: string; Icon: ComponentType<{ className?: string }>; - /** Small muted suffix, e.g. "soon" for a tool that is not integrated yet. */ - badge?: string; } interface PageTabsProps { @@ -34,7 +32,7 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps) aria-label={ariaLabel} className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2" > - {tabs.map(({ id, label, Icon, badge }) => { + {tabs.map(({ id, label, Icon }) => { const isActive = id === activeId; return ( ); })} diff --git a/client/src/components/config/ConfigTabBar.tsx b/client/src/components/config/ConfigTabBar.tsx new file mode 100644 index 00000000..cf09d95b --- /dev/null +++ b/client/src/components/config/ConfigTabBar.tsx @@ -0,0 +1,163 @@ +/** + * @file ConfigTabBar.tsx + * @description The tab strip both config explorers wear: one horizontally + * scrollable row with fade edges and chevrons that appear only when there is + * something to scroll to. + * + * Shared rather than copied because the two pages must look the same. Codex + * originally wrapped its tabs onto a second row — fine at eight tabs, but it + * read as a different component sitting next to Claude Code's twelve-tab + * scroller. One row that scrolls is the same shape at any tab count. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useRef, useState, type ComponentType } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +export interface ConfigTab { + key: K; + icon: ComponentType<{ className?: string }>; + label: string; + /** Badge number, or null for tabs that count nothing (e.g. Overview). */ + count: number | null; +} + +interface ConfigTabBarProps { + tabs: ConfigTab[]; + current: K; + onSelect: (key: K) => void; + /** `data-testid` becomes `-`, e.g. "cc-tab-plugins". */ + testIdPrefix: string; + ariaLabel: string; +} + +export function ConfigTabBar({ + tabs, + current, + onSelect, + testIdPrefix, + ariaLabel, +}: ConfigTabBarProps) { + const scrollRef = useRef(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('[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" }); + }; + + return ( +
+ {/* Left edge gradient + chevron */} +
+ {canScrollLeft && ( + + )} + +
+ {tabs.map(({ key, icon: Icon, label, count }) => { + const active = current === key; + return ( + + ); + })} +
+ + {/* Right edge gradient + chevron */} +
+ {canScrollRight && ( + + )} +
+ ); +} diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 52f981ab..6a067d06 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -445,10 +445,6 @@ "label": "Tools", "dashboard": "Dashboard", "claudeCode": "Claude Code", - "codex": "Codex", - "cursor": "Cursor", - "soonBadge": "soon", - "comingSoon": "{{tool}} is not integrated yet", - "comingSoonDesc": "Its configuration will show up here once support lands." + "codex": "Codex" } } diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index 977b037e..e5a8909a 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -445,10 +445,6 @@ "label": "Công cụ", "dashboard": "Dashboard", "claudeCode": "Claude Code", - "codex": "Codex", - "cursor": "Cursor", - "soonBadge": "sắp có", - "comingSoon": "{{tool}} chưa được tích hợp", - "comingSoonDesc": "Cấu hình của công cụ này sẽ hiện ở đây khi có hỗ trợ." + "codex": "Codex" } } diff --git a/client/src/pages/CcConfig.tsx b/client/src/pages/CcConfig.tsx index a93cb1fb..c597264a 100644 --- a/client/src/pages/CcConfig.tsx +++ b/client/src/pages/CcConfig.tsx @@ -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(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('[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 ( -
- {/* Left edge gradient + chevron */} -
- {canScrollLeft && ( - - )} - -
- {TABS.map(({ key, icon: Icon, i18nKey }) => { - const c = countFor(key); - const active = current === key; - return ( - - ); - })} -
- - {/* Right edge gradient + chevron */} -
- {canScrollRight && ( - - )} -
+ ({ + key, + icon, + label: t(i18nKey), + count: countFor(key), + }))} + current={current} + onSelect={onSelect} + testIdPrefix="cc-tab" + ariaLabel={t("title")} + /> ); } diff --git a/client/src/pages/CodexConfig.tsx b/client/src/pages/CodexConfig.tsx index 1d1d6de2..1ccd2edb 100644 --- a/client/src/pages/CodexConfig.tsx +++ b/client/src/pages/CodexConfig.tsx @@ -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 ( -
-
- {TABS.map(({ key, icon: Icon, i18nKey }) => { - const c = countFor(key); - const active = current === key; - return ( - - ); - })} -
-
+ ({ + key, + icon, + label: t(i18nKey), + count: countFor(key), + }))} + current={current} + onSelect={onSelect} + testIdPrefix="codex-tab" + ariaLabel={t("title")} + /> ); } diff --git a/client/src/pages/SettingsHub.tsx b/client/src/pages/SettingsHub.tsx index b81850b5..c81acabd 100644 --- a/client/src/pages/SettingsHub.tsx +++ b/client/src/pages/SettingsHub.tsx @@ -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ĩ */ 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 ? ( - - ) : ( -
-

- {t("tools.comingSoon", { tool: t(active.labelKey) })} -

-

{t("tools.comingSoonDesc")}

-
- )} +
); } diff --git a/client/src/pages/__tests__/Run.defaultCwd.test.tsx b/client/src/pages/__tests__/Run.defaultCwd.test.tsx index ea176ec4..a12fdedf 100644 --- a/client/src/pages/__tests__/Run.defaultCwd.test.tsx +++ b/client/src/pages/__tests__/Run.defaultCwd.test.tsx @@ -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)[fn]) { - (Element.prototype as unknown as Record)[fn] = function () {}; - } -} async function settle() { await act(async () => { diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index b64071d2..7be6c562 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -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" />
)[fn]) { - (Element.prototype as unknown as Record)[fn] = function () {}; - } -} // Flush pending promises (mocked API resolves) + effects so the loaded state // is rendered before snapshotting. diff --git a/client/src/test-setup.ts b/client/src/test-setup.ts index 8a750c9e..516d9a09 100644 --- a/client/src/test-setup.ts +++ b/client/src/test-setup.ts @@ -23,6 +23,13 @@ global.ResizeObserver = class { disconnect() {} }; +/** jsdom implements no scrolling — stub the methods the tab bars call. */ +for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) { + if (!(Element.prototype as unknown as Record)[fn]) { + (Element.prototype as unknown as Record)[fn] = function () {}; + } +} + /** Pin locale to English — LanguageDetector may otherwise pick up zh/vi from the host OS. */ beforeEach(() => { i18n.changeLanguage("en"); diff --git a/docs/CLI.md b/docs/CLI.md index b510982e..3d902cf0 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -110,7 +110,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it /\ \ /\ \ /\ \ /\ \ /::\ \ /::\ \ /::\ \ /::\____\ … (AgentHub word-mark) … - AgentHub · interactive shell · v1.4.6 ● 127.0.0.1:4820 + AgentHub · interactive shell · v1.5.0 ● 127.0.0.1:4820 Type commands without the 'agenthub' prefix — e.g. sessions --limit 5 help all commands · help details · Tab completes · ↑/↓ history · exit to quit diff --git a/server/openapi.js b/server/openapi.js index e8256ee6..aac30336 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -326,7 +326,7 @@ function createOpenApiSpec() { version: { type: "string", description: "Dashboard release version from package.json", - example: "1.4.6", + example: "1.5.0", }, timestamp: { type: "string", format: "date-time" }, },