diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 357d9ac203e3..73821580af07 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -7,7 +7,7 @@ const projectID = "proj_hidden_terminal_regression" const sessionID = "ses_hidden_terminal_regression" const title = "Hidden terminal regression" -test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => { +test("unmounts the terminal panel while it is hidden", async ({ page }) => { await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { directory, @@ -64,13 +64,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page }) await expect(page.locator('[data-component="terminal"]')).toBeVisible() await page.keyboard.press("Control+Backquote") - await expect(panel).toHaveAttribute("aria-hidden", "true") + await expect(panel).toHaveCount(0) await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) await page.setViewportSize({ width: 1200, height: 700 }) await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) await page.keyboard.press("Control+Backquote") + await expect(panel).toBeVisible() await expect(page.locator('[data-component="terminal"]')).toBeVisible() }) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index ce216028fb88..06602c279217 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,7 +1,7 @@ import type { Page, Route } from "@playwright/test" const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) -const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"]) +const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) export interface MockServerConfig { provider: unknown @@ -55,6 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const path = url.pathname if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/health") return json(route, { healthy: true }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") @@ -65,6 +66,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, await config.fileList(url.searchParams.get("path") ?? "")) if (path === "/file/content" && config.fileContent) return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) + if (path === "/api/reference") + return json(route, { + location: { directory: config.directory, project: { id: (config.project as { id?: string }).id, directory: config.directory } }, + data: [], + }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) if (path in staticRoutes) return json(route, staticRoutes[path]) @@ -75,6 +81,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, session ?? {}) } + const projectMatch = path.match(/^\/project\/([^/]+)$/) + if (projectMatch) return json(route, config.project) + const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/) if (messageMatch) { config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! }) diff --git a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx new file mode 100644 index 000000000000..f0cdf41ac1f5 --- /dev/null +++ b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx @@ -0,0 +1,271 @@ +import type { JSX } from "solid-js" +import { Show, createEffect, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { useSortable } from "@dnd-kit/solid/sortable" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tabs } from "@opencode-ai/ui/tabs" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title" +import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useLanguage } from "@/context/language" +import { focusTerminalById } from "@/pages/session/helpers" + +export function SortableTerminalTabV2(props: { + terminal: LocalPTY + index: () => number + newLayout: boolean + onClose?: () => void +}): JSX.Element { + const terminal = useTerminal() + const language = useLanguage() + const sortable = useSortable({ + get id() { + return props.terminal.id + }, + get index() { + return props.index() + }, + }) + const [store, setStore] = createStore({ + editing: false, + title: props.terminal.title, + menuOpen: false, + menuPosition: { x: 0, y: 0 }, + blurEnabled: false, + }) + let input: HTMLInputElement | undefined + let blurFrame: number | undefined + let editRequested = false + + const isDefaultTitle = () => { + const number = props.terminal.titleNumber + if (!Number.isFinite(number) || number <= 0) return false + return isDefaultTerminalTitle(props.terminal.title, number) + } + + const label = () => { + language.locale() + if (props.terminal.title && !isDefaultTitle()) return props.terminal.title + + const number = props.terminal.titleNumber + if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number }) + if (props.terminal.title) return props.terminal.title + return language.t("terminal.title") + } + + const close = () => { + const count = terminal.all().length + void terminal.close(props.terminal.id) + if (count === 1) { + props.onClose?.() + } + } + + const focus = () => { + if (store.editing) return + terminal.open(props.terminal.id) + if (document.activeElement instanceof HTMLElement) document.activeElement.blur() + focusTerminalById(props.terminal.id) + } + + const edit = (e?: Event) => { + if (e) { + e.stopPropagation() + e.preventDefault() + } + + setStore("blurEnabled", false) + setStore("title", props.terminal.title) + setStore("editing", true) + } + + const save = () => { + if (!store.blurEnabled) return + + const value = store.title.trim() + if (value && value !== props.terminal.title) { + terminal.update({ id: props.terminal.id, title: value }) + } + setStore("editing", false) + } + + const keydown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + save() + return + } + if (e.key === "Escape") { + e.preventDefault() + setStore("editing", false) + } + } + + const menu = (e: MouseEvent) => { + e.preventDefault() + setStore("menuPosition", { x: e.clientX, y: e.clientY }) + setStore("menuOpen", true) + } + + createEffect(() => { + if (!store.editing) return + if (!input) return + input.focus() + input.select() + if (blurFrame !== undefined) cancelAnimationFrame(blurFrame) + blurFrame = requestAnimationFrame(() => { + blurFrame = undefined + setStore("blurEnabled", true) + }) + }) + + onCleanup(() => { + if (blurFrame === undefined) return + cancelAnimationFrame(blurFrame) + }) + + return ( +
+ + e.preventDefault()} + onContextMenu={menu} + class="!shadow-none" + classes={{ + button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0", + }} + closeButton={ + { + e.stopPropagation() + close() + }} + aria-label={language.t("terminal.close")} + /> + } + > + + {label()} + + + +
+ setStore("title", e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" + /> +
+
+ setStore("menuOpen", open)}> + + { + if (!editRequested) return + e.preventDefault() + editRequested = false + requestAnimationFrame(() => edit()) + }} + > + (editRequested = true)}> + + {language.t("common.rename")} + + + + {language.t("common.close")} + + + + +
+ } + > + + + { + e.stopPropagation() + close() + }} + aria-label={language.t("terminal.close")} + /> + } + hideCloseButton + onMiddleClick={close} + > + + {label()} + + + +
+ setStore("title", e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + class="bg-transparent border-none outline-none min-w-0 flex-1 p-0 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:440] [font-variation-settings:'slnt'_0] [font-variant-numeric:tabular-nums]" + /> +
+
+
+ + { + if (!editRequested) return + e.preventDefault() + editRequested = false + requestAnimationFrame(() => edit()) + }} + > + (editRequested = true)}>{language.t("common.rename")} + {language.t("common.close")} + + +
+ + + ) +} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 68032e83c998..6705ac1919f0 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -17,8 +17,6 @@ import { createTabPromptState } from "@/context/prompt" import { base64Encode } from "@opencode-ai/core/util/encode" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" -const sortableTransition = { duration: 0 } - function SessionTabSlot(props: { tab: SessionTab id: string diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 30b6a5ebce96..db2c1f03e5d6 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -71,11 +71,13 @@ import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/ import { useSessionLayout } from "@/pages/session/session-layout" import { syncSessionModel } from "@/pages/session/session-model-helpers" import { SessionSidePanel } from "@/pages/session/session-side-panel" +import { sessionPanelLayout } from "@/pages/session/session-panel-layout" import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2" import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { TerminalPanel } from "@/pages/session/terminal-panel" +import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2" import { useComposerCommands } from "@/pages/session/use-composer-commands" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" @@ -427,6 +429,12 @@ export default function Page() { const isDesktop = createMediaQuery("(min-width: 768px)") const size = createSizing() const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) + const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id) + const terminalOpen = createMemo(() => view().terminal.opened()) + const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen()) + const desktopInlineTerminalOnlyOpen = createMemo( + () => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(), + ) const desktopFileTreeOpen = createMemo( () => isDesktop() && @@ -435,13 +443,23 @@ export default function Page() { opened: layout.fileTree.opened(), }), ) - const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) + const desktopSessionResizeOpen = createMemo( + () => (newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen()), + ) + const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen()) const sessionPanelWidth = createMemo(() => { if (!desktopSidePanelOpen()) return "100%" - if (desktopReviewOpen()) return `${layout.session.width()}px` + if (desktopSessionResizeOpen()) return `${layout.session.width()}px` return `calc(100% - ${layout.fileTree.width()}px)` }) const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) + const desktopV2PanelLayout = createMemo(() => + sessionPanelLayout({ + review: desktopV2ReviewOpen(), + terminal: desktopTerminalOpen(), + files: desktopFileTreeOpen(), + }), + ) function normalizeTab(tab: string) { if (!tab.startsWith("file://")) return tab @@ -2093,7 +2111,7 @@ export default function Page() { classList={{ "@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true, "duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": - !size.active() && !ui.reviewSnap, + !size.active() && !ui.reviewSnap && !desktopInlineTerminalOnlyOpen(), }} style={{ width: sessionPanelWidth(), @@ -2113,7 +2131,7 @@ export default function Page() { )} - +
size.start()}>
- (newSessionDesign() ? reviewPanelV2() : reviewPanel())} - activeDiff={tree.activeDiff} - focusReviewDiff={focusReviewDiff} - reviewSnap={ui.reviewSnap} - size={size} - /> + + + + + +
+ +
+ +
+
+ +
size.start()}> + { + size.touch() + layout.terminal.resize(height) + }} + onCollapse={() => view().terminal.close()} + /> +
+
+ +
+ +
+
+
+
+
- + + + ) } diff --git a/packages/app/src/pages/session/session-panel-layout.test.ts b/packages/app/src/pages/session/session-panel-layout.test.ts new file mode 100644 index 000000000000..499dd7411a0b --- /dev/null +++ b/packages/app/src/pages/session/session-panel-layout.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { sessionPanelLayout } from "./session-panel-layout" + +describe("sessionPanelLayout", () => { + test("keeps one V2 owner while changing panel geometry", () => { + expect(sessionPanelLayout({ review: false, terminal: false, files: false })).toEqual({ + visible: false, + stacked: false, + }) + expect(sessionPanelLayout({ review: false, terminal: true, files: false })).toEqual({ + visible: true, + stacked: false, + }) + expect(sessionPanelLayout({ review: true, terminal: true, files: false })).toEqual({ + visible: true, + stacked: true, + }) + }) +}) diff --git a/packages/app/src/pages/session/session-panel-layout.ts b/packages/app/src/pages/session/session-panel-layout.ts new file mode 100644 index 000000000000..49feaebedeef --- /dev/null +++ b/packages/app/src/pages/session/session-panel-layout.ts @@ -0,0 +1,6 @@ +export function sessionPanelLayout(input: { review: boolean; terminal: boolean; files: boolean }) { + return { + visible: input.review || input.terminal || input.files, + stacked: input.review && input.terminal, + } +} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 097df2219376..690262102039 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -51,6 +51,7 @@ export function SessionSidePanel(props: { focusReviewDiff: (path: string) => void reviewSnap: boolean size: Sizing + stacked?: boolean }) { const layout = useLayout() const settings = useSettings() @@ -219,8 +220,10 @@ export function SessionSidePanel(props: { aria-label={language.t("session.panel.reviewAndFiles")} aria-hidden={!open()} inert={!open()} - class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base" + class="relative min-w-0 flex overflow-hidden bg-background-base" classList={{ + "h-full shrink-0": !props.stacked, + "min-h-0 flex-1": props.stacked, "pointer-events-none": !open(), "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": !props.size.active() && !props.reviewSnap, diff --git a/packages/app/src/pages/session/terminal-panel-v2.tsx b/packages/app/src/pages/session/terminal-panel-v2.tsx new file mode 100644 index 000000000000..aec999d5dc15 --- /dev/null +++ b/packages/app/src/pages/session/terminal-panel-v2.tsx @@ -0,0 +1,364 @@ +import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { makeEventListener } from "@solid-primitives/event-listener" +import { createMediaQuery } from "@solid-primitives/media" +import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable } from "@dnd-kit/solid/sortable" +import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" +import { RestrictToElement } from "@dnd-kit/dom/modifiers" +import { Tabs } from "@opencode-ai/ui/tabs" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" + +import { SortableTerminalTabV2 } from "@/components/session/session-sortable-terminal-tab-v2" +import { Terminal } from "@/components/terminal" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useSettings } from "@/context/settings" +import { useTerminal } from "@/context/terminal" +import { useSDK } from "@/context/sdk" +import { terminalTabLabel } from "@/pages/session/terminal-label" +import { createSizing, focusTerminalById } from "@/pages/session/helpers" +import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" +import { useSessionLayout } from "@/pages/session/session-layout" + +export function TerminalPanelV2(props: { stacked?: boolean } = {}) { + const delays = [120, 240] + const layout = useLayout() + const terminal = useTerminal() + const sdk = useSDK() + const language = useLanguage() + const command = useCommand() + const settings = useSettings() + const { workspaceKey, view } = useSessionLayout() + + const isDesktop = createMediaQuery("(min-width: 768px)") + const newLayout = createMemo(() => settings.general.newLayoutDesigns()) + const opened = createMemo(() => view().terminal.opened()) + const size = createSizing() + const height = createMemo(() => layout.terminal.height()) + const close = () => view().terminal.close() + let root: HTMLDivElement | undefined + let tabList: HTMLDivElement | undefined + + const [store, setStore] = createStore({ + autoCreated: false, + recovered: {} as Record, + view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight), + }) + + const max = () => store.view * 0.6 + const pane = () => Math.min(height(), max()) + const stacked = createMemo(() => isDesktop() && props.stacked) + const panelHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px")) + const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`)) + const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new")) + + onMount(() => { + if (typeof window === "undefined") return + + const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight) + const port = window.visualViewport + + sync() + makeEventListener(window, "resize", sync) + if (port) makeEventListener(port, "resize", sync) + }) + + createEffect(() => { + if (!opened()) { + setStore("autoCreated", false) + return + } + + if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return + terminal.new() + setStore("autoCreated", true) + }) + + createEffect( + on( + () => terminal.all().length, + (count, prevCount) => { + if (prevCount === undefined || prevCount <= 0 || count !== 0) return + if (!opened()) return + close() + }, + ), + ) + + const focus = (id: string) => { + focusTerminalById(id) + + const frame = requestAnimationFrame(() => { + if (!opened()) return + if (terminal.active() !== id) return + focusTerminalById(id) + }) + + const timers = delays.map((ms) => + window.setTimeout(() => { + if (!opened()) return + if (terminal.active() !== id) return + focusTerminalById(id) + }, ms), + ) + + return () => { + cancelAnimationFrame(frame) + for (const timer of timers) clearTimeout(timer) + } + } + + createEffect( + on( + () => [opened(), terminal.active()] as const, + ([next, id]) => { + if (!next || !id) return + const stop = focus(id) + onCleanup(stop) + }, + ), + ) + + createEffect(() => { + if (opened()) return + const active = document.activeElement + if (!(active instanceof HTMLElement)) return + if (!root?.contains(active)) return + active.blur() + }) + + createEffect(() => { + const dir = sdk().directory + if (!dir) return + if (!terminal.ready()) return + language.locale() + + setTerminalHandoff( + workspaceKey(), + terminal.all().map((pty) => + terminalTabLabel({ + title: pty.title, + titleNumber: pty.titleNumber, + t: language.t as (key: string, vars?: Record) => string, + }), + ), + ) + }) + + const handoff = createMemo(() => { + const dir = sdk().directory + if (!dir) return [] + return getTerminalHandoff(workspaceKey()) ?? [] + }) + + const all = terminal.all + const ids = createMemo(() => all().map((pty) => pty.id)) + + const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise) => { + if (store.recovered[key]) return + setStore("recovered", key, true) + void clone(id) + } + + const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => { + return String(pty.titleNumber || pty.title || pty.id) + } + + const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => { + setStore("recovered", key, false) + trim(id) + } + + const handleTerminalDragEnd = () => { + const activeId = terminal.active() + if (!activeId) return + requestAnimationFrame(() => { + if (terminal.active() !== activeId) return + focusTerminalById(activeId) + }) + } + + return ( + + ) +} diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 036533c10fb8..fe450da85a5e 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -135,7 +135,8 @@ } } - #review-panel &[data-variant="normal"][data-orientation="horizontal"] { + #review-panel &[data-variant="normal"][data-orientation="horizontal"], + #terminal-panel &[data-variant="normal"][data-orientation="horizontal"] { background-color: var(--background-stronger); [data-slot="tabs-list"] { @@ -267,6 +268,51 @@ } } + #terminal-panel &[data-variant="normal"][data-orientation="horizontal"] { + [data-slot="tabs-list"] { + height: 52px; + padding-right: 12px; + gap: 8px; + } + + [data-slot="tabs-trigger-wrapper"] { + height: 28px; + padding-inline: 8px; + border: 0; + background-color: transparent; + box-shadow: none; + + &::after { + display: none; + } + + [data-slot="tabs-trigger"] { + padding: 0 !important; + } + + &:has([data-slot="tabs-trigger-close-button"]) { + padding-right: 8px; + } + + &:has([data-selected]) { + background-color: var(--v2-background-bg-layer-01); + box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); + } + } + + [data-slot="terminal-tab-title"] { + font-family: Inter, sans-serif; + font-style: normal; + font-weight: 440; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.04px; + color: var(--v2-text-text-base); + font-variation-settings: "slnt" 0; + font-variant-numeric: tabular-nums; + } + } + &[data-variant="alt"] { [data-slot="tabs-list"] { padding-left: 24px;