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" }, },