From 76d3bc802b3bd93405993cb9effceff5ee0e2c76 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:02:01 +0530 Subject: [PATCH 01/11] switch to dnd kit, ui updates --- .../session/session-sortable-tab.tsx | 14 +- packages/app/src/i18n/en.ts | 2 +- packages/app/src/pages/session.tsx | 7 + .../src/pages/session/session-side-panel.tsx | 210 +++++++++--------- .../src/pages/session/v2/review-panel-v2.tsx | 4 - .../session/v2/session-file-browser-tab.tsx | 11 +- .../src/v2/components/session-review-v2.tsx | 2 - packages/ui/src/components/tabs.css | 39 +++- 8 files changed, 157 insertions(+), 132 deletions(-) diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index d78f392941ac..39f505370f8b 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -1,6 +1,6 @@ import { createMemo, Show } from "solid-js" import type { JSX } from "solid-js" -import { createSortable } from "@thisbeyond/solid-dnd" +import { useSortable } from "@dnd-kit/solid/sortable" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" @@ -31,6 +31,7 @@ export function FileVisual(props: { path: string; active?: boolean; temporary?: export function SortableTab(props: { tab: string + index: () => number temporary?: boolean onTabClose: (tab: string) => void onTabDoubleClick?: (tab: string) => void @@ -38,7 +39,14 @@ export function SortableTab(props: { const file = useFile() const language = useLanguage() const command = useCommand() - const sortable = createSortable(props.tab) + const sortable = useSortable({ + get id() { + return props.tab + }, + get index() { + return props.index() + }, + }) const path = createMemo(() => file.pathFromTab(props.tab)) const content = createMemo(() => { const value = path() @@ -46,7 +54,7 @@ export function SortableTab(props: { return }) return ( -
+
hasReview() || reviewV2State.sidebarOpened()} reviewCount={reviewCount} reviewPanel={reviewPanelV2} + reviewSidebarToggle={() => ( + + )} fileBrowserState={reviewV2State} activeDiff={activeReviewFile()} focusReviewDiff={focusReviewDiff} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index e009177682a0..839c2358bccb 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -1,16 +1,20 @@ import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" -import { createStore } from "solid-js/store" 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 { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" -import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" -import type { DragEvent } from "@thisbeyond/solid-dnd" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" -import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" import FileTree from "@/components/file-tree" @@ -18,7 +22,7 @@ import { SessionContextUsage } from "@/components/session-context-usage" const reviewTabID = "session-side-panel-review-tab" const reviewTabPanelID = "session-side-panel-review-tabpanel" -import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" +import { SessionContextTab, SortableTab } from "@/components/session" import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" @@ -30,7 +34,6 @@ import { SESSION_OPEN_FILE_TAB, createOpenSessionFileTab, createSessionTabs, - getTabReorderIndex, shouldShowFileTree, type Sizing, } from "@/pages/session/helpers" @@ -53,6 +56,7 @@ export function SessionSidePanel(props: { reviewHasFocusableContent: () => boolean reviewCount: () => number reviewPanel: () => JSX.Element + reviewSidebarToggle?: () => JSX.Element fileBrowserState?: SessionFileBrowserState activeDiff?: string focusReviewDiff: (path: string) => void @@ -176,10 +180,8 @@ export function SessionSidePanel(props: { layout.fileTree.setTab("all") } - const [store, setStore] = createStore({ - activeDraggable: undefined as string | undefined, - }) let fileFilter: HTMLInputElement | undefined + let tabList: HTMLDivElement | undefined const temporaryTab = tabs().preview const previewTab = (value: string) => { const next = normalizeTab(value) @@ -206,26 +208,7 @@ export function SessionSidePanel(props: { return activeFileTab() }) const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix"))) - - const handleDragStart = (event: unknown) => { - const id = getDraggableId(event) - if (!id) return - setStore("activeDraggable", id) - } - - const handleDragOver = (event: DragEvent) => { - const { draggable, droppable } = event - if (!draggable || !droppable) return - - const currentTabs = tabs().all() - const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString()) - if (toIndex === undefined) return - tabs().move(draggable.id.toString(), toIndex) - } - - const handleDragEnd = () => { - setStore("activeDraggable", undefined) - } + const openFileKeybind = createMemo(() => command.keybindParts("file.open")) createEffect(() => { if (!file.ready()) return @@ -292,33 +275,52 @@ export function SessionSidePanel(props: { }} > + event.target instanceof Element && + !!event.target.closest('[data-slot="tabs-trigger-close-button"]'), + }), + ]} + modifiers={[ + RestrictToHorizontalAxis, + RestrictToElement.configure({ element: () => tabList ?? null }), + ]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + const source = event.operation.source + if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return + tabs().move(source.id.toString(), source.index) + }} > - -
{ + tabList = el const stop = createFileTabListSync({ el, contextOpen }) onCleanup(stop) }} > + + {(toggle) => ( +
{toggle()()}
+ )} +
-
-
{language.t("session.tab.review")}
- -
{props.reviewCount()}
-
-
+ {props.hasReview() + ? language.t("session.review.filesChanged", { count: props.reviewCount() }) + : language.t("session.tab.review")}
@@ -349,50 +351,49 @@ export function SessionSidePanel(props: {
- - - {(tab) => ( - + + {(tab) => ( + tabs().all().indexOf(tab)} + temporary={temporaryTab() === tab} + onTabClose={tabs().close} + onTabDoubleClick={temporaryTab() === tab ? openTab : undefined} + /> + } + > + + tabs().close(SESSION_OPEN_FILE_TAB)} + aria-label={language.t("common.closeTab")} + /> + } + hideCloseButton + onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} > - - tabs().close(SESSION_OPEN_FILE_TAB)} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} - > -
- - {language.t("command.file.open")} -
-
-
- )} -
-
+
+ + {language.t("command.file.open")} +
+ + + )} +
- + {language.t("command.file.open")} + 0}> + + + + } + placement="bottom" class="flex items-center" > - } + variant="ghost-muted" + size="large" onClick={() => { if (props.fileBrowserState) { openFileBrowser() @@ -421,7 +428,7 @@ export function SessionSidePanel(props: { }} aria-label={language.t("command.file.open")} /> - +
@@ -477,20 +484,6 @@ export function SessionSidePanel(props: { {(tab) => } - - - {(tab) => { - const path = file.pathFromTab(tab) - return ( -
- - {(p) => } - -
- ) - }} -
-
@@ -519,10 +512,7 @@ export function SessionSidePanel(props: { > - {props.reviewCount()}{" "} - {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", - )} + {language.t("session.review.filesChanged", { count: props.reviewCount() })} {language.t("session.files.all")} diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index a202a1e39dae..8956e45b29ef 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -5,7 +5,6 @@ import { SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, SessionReviewV2, SessionReviewV2Sidebar, - SessionReviewV2SidebarToggle, } from "@opencode-ai/session-ui/v2/session-review-v2" import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2" import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2" @@ -113,9 +112,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { stats={} empty={props.empty} sidebarOpen={props.state.sidebarOpened()} - sidebarToggle={ - - } sidebar={ // Always mounted: the sidebar header hosts the changes-mode dropdown, // which must stay reachable when the current mode has zero diffs. diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx index 9cbdd40df1f9..e57a6acb3dee 100644 --- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx +++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx @@ -7,7 +7,7 @@ import { SessionFilePanelV2Empty, SessionFilePanelV2Title, } from "@opencode-ai/session-ui/v2/session-file-panel-v2" -import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" +import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2" import FileTree, { type Kind } from "@/components/file-tree" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" @@ -98,12 +98,9 @@ export function SessionFileBrowserTab(props: { - - - {title()} - - + + {title()} + } sidebar={ void @@ -199,7 +198,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) { const toolbarStart = () => ( <> - {props.sidebarToggle}
diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index cd26b0cd5062..3e91be2bd239 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -202,11 +202,6 @@ transform 120ms ease; } - &[data-value="review"] { - padding-left: 8px; - padding-right: 8px; - } - [data-slot="tabs-trigger"] { height: 100%; padding: 0 !important; @@ -268,6 +263,7 @@ } } + #review-panel &[data-variant="normal"][data-orientation="horizontal"], #terminal-panel &[data-variant="normal"][data-orientation="horizontal"] { [data-slot="tabs-list"] { height: 52px; @@ -300,6 +296,7 @@ } } + [data-slot="tabs-trigger"], [data-slot="terminal-tab-title"] { font-family: Inter, sans-serif; font-style: normal; @@ -313,6 +310,38 @@ } } + #review-panel &[data-variant="normal"][data-orientation="horizontal"] { + [data-slot="tabs-list"] { + --tabs-review-fade: 8px; + } + + [data-slot="tabs-trigger-wrapper"] { + padding-inline: 10px; + + &:has([data-slot="tabs-trigger-close-button"]) { + padding-right: 10px; + } + } + + [data-slot="tabs-trigger-wrapper"]:has([data-selected]) { + background-color: var(--v2-background-bg-layer-02); + } + + [data-slot="tabs-trigger"] { + color: var(--v2-text-text-muted); + + &[data-selected] { + color: var(--v2-text-text-base); + } + + .tab-fileicon-color, + .tab-fileicon-mono, + [data-slot="icon-svg"] { + margin-left: -2px; + } + } + } + &[data-variant="alt"] { [data-slot="tabs-list"] { padding-left: 24px; From ccc5b3d79d4c1c86ebf9292519c22f48f7e8644b Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:21:03 +0530 Subject: [PATCH 02/11] italic name for preview tabs --- packages/ui/src/components/tabs.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 3e91be2bd239..4a4a3858220e 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -308,6 +308,12 @@ font-variation-settings: "slnt" 0; font-variant-numeric: tabular-nums; } + + /* Preview/temporary tabs: beat trigger + .text-14-medium upright defaults */ + [data-slot="tabs-trigger"] .italic { + font-style: italic !important; + font-variation-settings: "slnt" -10 !important; + } } #review-panel &[data-variant="normal"][data-orientation="horizontal"] { From ffd999d3f5ea72f399bc307f5baa6df171c73399 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:23:55 +0530 Subject: [PATCH 03/11] remove toolbar in file tabs --- .../pages/session/v2/session-file-browser-tab.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx index e57a6acb3dee..29272a4fa637 100644 --- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx +++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx @@ -2,11 +2,7 @@ import { createMemo, createSignal, createUniqueId, Show } from "solid-js" import { createQuery } from "@tanstack/solid-query" import { Tabs } from "@opencode-ai/ui/tabs" import { Icon } from "@opencode-ai/ui/icon" -import { - SessionFilePanelV2, - SessionFilePanelV2Empty, - SessionFilePanelV2Title, -} from "@opencode-ai/session-ui/v2/session-file-panel-v2" +import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2" import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2" import FileTree, { type Kind } from "@/components/file-tree" import { useFile } from "@/context/file" @@ -96,12 +92,7 @@ export function SessionFileBrowserTab(props: { return ( - {title()} - - } + toolbar={false} sidebar={ Date: Fri, 10 Jul 2026 16:33:07 +0530 Subject: [PATCH 04/11] bring back open in... button --- .../src/components/session/open-in-app-v2.tsx | 92 +++++++ .../src/components/session/open-in-app.tsx | 232 ++++++++++++++++++ .../ui/src/v2/components/split-button-v2.css | 99 ++++++++ .../ui/src/v2/components/split-button-v2.tsx | 48 ++++ 4 files changed, 471 insertions(+) create mode 100644 packages/app/src/components/session/open-in-app-v2.tsx create mode 100644 packages/app/src/components/session/open-in-app.tsx create mode 100644 packages/ui/src/v2/components/split-button-v2.css create mode 100644 packages/ui/src/v2/components/split-button-v2.tsx diff --git a/packages/app/src/components/session/open-in-app-v2.tsx b/packages/app/src/components/session/open-in-app-v2.tsx new file mode 100644 index 000000000000..df633a8ded8c --- /dev/null +++ b/packages/app/src/components/session/open-in-app-v2.tsx @@ -0,0 +1,92 @@ +import { For, Show } from "solid-js" +import { AppIcon } from "@opencode-ai/ui/app-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useLanguage } from "@/context/language" +import { OPEN_APPS, type OpenApp, useOpenInApp } from "@/components/session/open-in-app" + +export function OpenInAppV2(props: { directory: () => string }) { + const language = useLanguage() + const state = useOpenInApp(props) + + return ( + + + + state.openDir(state.current().id)} + disabled={state.opening()} + aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })} + > + }> + + + + + state.setMenu("open", open)} + > + + + + + + + {language.t("session.header.openIn")} + { + if (!OPEN_APPS.includes(value as OpenApp)) return + state.selectApp(value as OpenApp) + }} + > + + {(option) => ( + { + state.setMenu("open", false) + state.openDir(option.id) + }} + > + + {option.label} + + )} + + + + + { + state.setMenu("open", false) + state.copyPath() + }} + > + + {language.t("session.header.open.copyPath")} + + + + + + + ) +} diff --git a/packages/app/src/components/session/open-in-app.tsx b/packages/app/src/components/session/open-in-app.tsx new file mode 100644 index 000000000000..0d5cd9067122 --- /dev/null +++ b/packages/app/src/components/session/open-in-app.tsx @@ -0,0 +1,232 @@ +import { createEffect, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" +import { Persist, persisted } from "@/utils/persist" +import { showToast } from "@/utils/toast" + +export const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "warp", + "xcode", + "android-studio", + "powershell", + "sublime-text", +] as const + +export type OpenApp = (typeof OPEN_APPS)[number] +export type OpenAppOS = "macos" | "windows" | "linux" | "unknown" + +export const MAC_OPEN_APPS = [ + { + id: "vscode", + label: "session.header.open.app.vscode", + icon: "vscode", + openWith: "Visual Studio Code", + }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" }, + { + id: "antigravity", + label: "session.header.open.app.antigravity", + icon: "antigravity", + openWith: "Antigravity", + }, + { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" }, + { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" }, + { + id: "android-studio", + label: "session.header.open.app.androidStudio", + icon: "android-studio", + openWith: "Android Studio", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export const WINDOWS_OPEN_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "powershell", + label: "session.header.open.app.powershell", + icon: "powershell", + openWith: "powershell", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export const LINUX_OPEN_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export function detectOpenAppOS(platform: ReturnType): OpenAppOS { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" +} + +export function openAppFileManager(os: OpenAppOS) { + if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const } + if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const } + return { label: "session.header.open.fileManager", icon: "finder" as const } +} + +export function openAppsForOS(os: OpenAppOS) { + if (os === "macos") return MAC_OPEN_APPS + if (os === "windows") return WINDOWS_OPEN_APPS + return LINUX_OPEN_APPS +} + +const showRequestError = (language: ReturnType, err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +export function useOpenInApp(input: { directory: () => string }) { + const platform = usePlatform() + const server = useServer() + const language = useLanguage() + + const os = createMemo(() => detectOpenAppOS(platform)) + const apps = createMemo(() => openAppsForOS(os())) + const fileManager = createMemo(() => openAppFileManager(os())) + + const [exists, setExists] = createStore>>({ + finder: true, + }) + + createEffect(() => { + if (platform.platform !== "desktop") return + if (!platform.checkAppExists) return + + const list = apps() + + setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) + + void Promise.all( + list.map((app) => + Promise.resolve(platform.checkAppExists?.(app.openWith)) + .then((value) => Boolean(value)) + .catch(() => false) + .then((ok) => [app.id, ok] as const), + ), + ).then((entries) => { + setExists(Object.fromEntries(entries) as Partial>) + }) + }) + + const options = createMemo(() => { + return [ + { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon }, + ...apps() + .filter((app) => exists[app.id]) + .map((app) => ({ ...app, label: language.t(app.label) })), + ] as const + }) + + const [prefs, setPrefs] = persisted( + Persist.global("open.app"), + createStore({ app: "finder" as OpenApp | "finder" }), + ) + const [menu, setMenu] = createStore({ open: false }) + const [openRequest, setOpenRequest] = createStore({ + app: undefined as OpenApp | undefined, + }) + + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const current = createMemo( + () => + options().find((o) => o.id === prefs.app) ?? + options()[0] ?? + ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), + ) + const opening = createMemo(() => openRequest.app !== undefined) + + const selectApp = (app: OpenApp | "finder") => { + if (!options().some((item) => item.id === app)) return + setPrefs("app", app) + } + + const openDir = (app: OpenApp | "finder") => { + if (opening() || !canOpen() || !platform.openPath) return + const directory = input.directory() + if (!directory) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + setOpenRequest("app", app) + platform + .openPath(directory, openWith) + .catch((err: unknown) => showRequestError(language, err)) + .finally(() => { + setOpenRequest("app", undefined) + }) + } + + const copyPath = () => { + const directory = input.directory() + if (!directory) return + navigator.clipboard + .writeText(directory) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: directory, + }) + }) + .catch((err: unknown) => showRequestError(language, err)) + } + + return { + canOpen, + opening, + current, + options, + menu, + setMenu, + openDir, + selectApp, + copyPath, + } +} diff --git a/packages/ui/src/v2/components/split-button-v2.css b/packages/ui/src/v2/components/split-button-v2.css new file mode 100644 index 000000000000..e8469f494d5a --- /dev/null +++ b/packages/ui/src/v2/components/split-button-v2.css @@ -0,0 +1,99 @@ +[data-component="split-button-v2"] { + display: inline-flex; + align-items: stretch; + flex-shrink: 0; + height: 28px; + border-radius: 6px; + background-color: transparent; + box-shadow: none; + overflow: hidden; +} + +[data-component="split-button-v2"]:is(:hover, :has([data-component="split-button-v2-menu-trigger"][data-expanded])) { + box-shadow: inset 0 0 0 1px var(--v2-border-border-muted); +} + +[data-component="split-button-v2-action"], +[data-component="split-button-v2-menu-trigger"] { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + margin: 0; + padding: 0; + border: none; + background: transparent; + color: var(--v2-icon-icon-base); + cursor: default; + outline: none; +} + +[data-component="split-button-v2-action"] { + width: 28px; + flex-shrink: 0; +} + +[data-component="split-button-v2-action"]:hover:not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="split-button-v2-action"]:is(:active, [data-pressed]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="split-button-v2-action"]:is(:focus-visible, [data-focus]) { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: -2px; + z-index: 1; +} + +[data-component="split-button-v2-action"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +[data-component="split-button-v2-menu-trigger"] { + width: 20px; + flex-shrink: 0; +} + +[data-component="split-button-v2-menu-trigger"]:is(:hover, [data-expanded]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="split-button-v2-menu-trigger"]:is(:active, [data-pressed]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="split-button-v2-menu-trigger"]:is(:focus-visible, [data-focus]) { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: -2px; + z-index: 1; +} + +[data-component="split-button-v2-menu-trigger"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +[data-component="split-button-v2-action"] [data-component="app-icon"] { + width: 16px; + height: 16px; +} + +[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="app-icon"] { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="icon"] { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="icon"] [data-slot="icon-svg"] { + width: 16px; + height: 16px; +} diff --git a/packages/ui/src/v2/components/split-button-v2.tsx b/packages/ui/src/v2/components/split-button-v2.tsx new file mode 100644 index 000000000000..3475db1c775d --- /dev/null +++ b/packages/ui/src/v2/components/split-button-v2.tsx @@ -0,0 +1,48 @@ +import { splitProps, type ComponentProps, type ParentProps } from "solid-js" +import "./split-button-v2.css" + +export function SplitButtonV2(props: ParentProps<{ class?: string; classList?: ComponentProps<"div">["classList"] }>) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( +
+ {split.children} +
+ ) +} + +export function SplitButtonV2Action(props: ComponentProps<"button">) { + const [split, rest] = splitProps(props, ["class", "classList"]) + return ( + + ) + }} + +
+
{local.selection}
From 6eddfeb4dbc29e9dfef0166552ff575388b1bc5c Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:04:57 +0530 Subject: [PATCH 09/11] cleanup --- packages/app/src/components/file-tree-v2.tsx | 2 +- packages/ui/src/v2/components/file-tree-v2.css | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index c712a8044360..7e961e2df9f2 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -112,7 +112,7 @@ function GuideLines(props: { level: number }) { {(_, index) => (
)} diff --git a/packages/ui/src/v2/components/file-tree-v2.css b/packages/ui/src/v2/components/file-tree-v2.css index e4236bcccd1d..61f6bfe3f747 100644 --- a/packages/ui/src/v2/components/file-tree-v2.css +++ b/packages/ui/src/v2/components/file-tree-v2.css @@ -14,6 +14,7 @@ justify-content: flex-start; gap: 6px; padding-right: 8px; + overflow: visible; border: none; border-radius: 6px; background-color: transparent; From 00ac880d53a84969249d5c6712823b932bf2fa6a Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:12:44 +0530 Subject: [PATCH 10/11] update file tree styles --- .../src/components/file-tree-v2-model.test.ts | 44 ++++++++-- .../app/src/components/file-tree-v2-model.ts | 30 +++++++ packages/app/src/components/file-tree-v2.tsx | 87 +++++++++++++------ .../src/pages/session/session-side-panel.tsx | 8 +- .../src/pages/session/v2/review-panel-v2.tsx | 4 +- .../session/v2/session-file-browser-tab.tsx | 6 +- .../ui/src/v2/components/file-tree-v2.css | 18 +++- 7 files changed, 151 insertions(+), 46 deletions(-) diff --git a/packages/app/src/components/file-tree-v2-model.test.ts b/packages/app/src/components/file-tree-v2-model.test.ts index 288f7112e929..f2a63fe86d3d 100644 --- a/packages/app/src/components/file-tree-v2-model.test.ts +++ b/packages/app/src/components/file-tree-v2-model.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test" -import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model" +import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model" +import type { FileNode } from "@opencode-ai/sdk/v2" -describe("file tree v2 model", () => { - test("builds sorted depth-first rows", () => { +describe("buildFileTreeV2Model", () => { + test("builds a sorted tree and flattens expanded directories", () => { const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"]) expect(model.total).toBe(8) @@ -18,7 +19,7 @@ describe("file tree v2 model", () => { ]) }) - test("omits descendants of collapsed directories", () => { + test("skips children of collapsed directories", () => { const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"]) expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([ @@ -28,19 +29,46 @@ describe("file tree v2 model", () => { ]) }) - test("normalizes separators and duplicate paths", () => { + test("normalizes duplicate and messy paths", () => { const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"]) const rows = flattenFileTreeV2(model, () => true) - expect(model.total).toBe(4) expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"]) expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts") }) - test("supports paths deeper than the legacy recursion limit", () => { - const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts` + test("handles deeply nested paths", () => { + const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts" const model = buildFileTreeV2Model([file]) expect(flattenFileTreeV2(model, () => true)).toHaveLength(131) }) }) + +describe("flattenLiveFileTreeV2", () => { + test("flattens live children using original paths for nested lookups", () => { + const nodes: Record = { + "": [ + { name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false }, + { name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false }, + ], + src: [ + { name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false }, + { name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false }, + ], + "src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }], + } + + expect( + flattenLiveFileTreeV2( + (path) => nodes[path] ?? [], + (path) => path === "src", + ).map((row) => [row.node.path, row.node.originalPath, row.level]), + ).toEqual([ + ["src", "src", 0], + ["src/a.ts", "src/a.ts", 1], + ["src/lib", "src/lib", 1], + ["README.md", "README.md", 0], + ]) + }) +}) diff --git a/packages/app/src/components/file-tree-v2-model.ts b/packages/app/src/components/file-tree-v2-model.ts index 2127b6800f86..27783a9bb16b 100644 --- a/packages/app/src/components/file-tree-v2-model.ts +++ b/packages/app/src/components/file-tree-v2-model.ts @@ -75,3 +75,33 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin return rows } + +export function flattenLiveFileTreeV2( + children: (path: string) => readonly FileNode[], + expanded: (path: string) => boolean, +) { + const rows: FileTreeV2Row[] = [] + const stack = children("") + .toReversed() + .map((node) => ({ node: toLiveNode(node), level: 0 })) + + while (stack.length > 0) { + const row = stack.pop()! + rows.push(row) + if (row.node.type !== "directory" || !expanded(row.node.path)) continue + const nested = children(row.node.originalPath) + for (let index = nested.length - 1; index >= 0; index--) { + stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 }) + } + } + + return rows +} + +function toLiveNode(node: FileNode): FileTreeV2Node { + return { + ...node, + path: normalizeFileTreeV2Path(node.path), + originalPath: node.path, + } +} diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index 7e961e2df9f2..bb65d44ea854 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -16,7 +16,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" -import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" +import { + buildFileTreeV2Model, + flattenFileTreeV2, + flattenLiveFileTreeV2, + normalizeFileTreeV2Path, + type FileTreeV2Node, +} from "@/components/file-tree-v2-model" import { virtualScrollElement } from "@/components/virtual-scroll-element" export type { Kind } from "@/components/file-tree" @@ -36,7 +42,7 @@ function guideLineLeft(level: number) { export const kindLabel = (kind: Kind) => { if (kind === "add") return "A" if (kind === "del") return "D" - return "" + return "M" } export const kindChange = (kind: Kind) => { @@ -68,7 +74,7 @@ const FileTreeNodeV2 = ( "class", "classList", ]) - const kind = () => local.kinds?.get(local.node.path) + const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path)) return ( {(_, index) => ( -
+
)} ) @@ -126,12 +129,18 @@ export default function FileTreeV2(props: { kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void + onFileDoubleClick?: (file: FileNode) => void }) { const file = useFile() + const live = () => props.allowed === undefined const draggable = () => props.draggable ?? true const active = () => normalizeFileTreeV2Path(props.active ?? "") - const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? [])) - const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true)) + const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? []))) + const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live() + const rows = createMemo(() => { + if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded) + return flattenFileTreeV2(model()!, expanded) + }) const [root, setRoot] = createSignal() const [focused, setFocused] = createSignal() const virtualizer = createVirtualizer({ @@ -155,16 +164,49 @@ export default function FileTreeV2(props: { return [...indexes, index].sort((a, b) => a - b) }, }) + + createEffect(() => { + if (!live()) return + void file.tree.list("") + }) + + // Only scroll when the active path changes (or first appears in the tree). + // Do not re-scroll when expand/collapse reshuffles `rows()`. + let scrolledActive: string | undefined createEffect(() => { const path = active() - if (!path) return + if (!path) { + scrolledActive = undefined + return + } const index = rows().findIndex((row) => row.node.path === path) if (index < 0) return + if (scrolledActive === path) return + scrolledActive = path queueMicrotask(() => { - if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return - virtualizer.scrollToIndex(index, { align: "auto" }) + const next = rows().findIndex((row) => row.node.path === path) + if (next < 0) return + if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return + virtualizer.scrollToIndex(next, { align: "auto" }) }) }) + + const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => { + action?.({ + ...node, + path: node.originalPath, + absolute: node.originalPath, + }) + } + + const toggleDirectory = (path: string, originalPath: string) => { + if (expanded(path)) { + file.tree.collapse(originalPath) + return + } + file.tree.expand(originalPath, live() ? undefined : { list: false }) + } + const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) const virtualItemByKey = createMemo( () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), @@ -175,7 +217,7 @@ export default function FileTreeV2(props: {
@@ -209,13 +251,8 @@ export default function FileTreeV2(props: { class="relative" onFocus={() => setFocused(row().node.path)} onBlur={() => setFocused(undefined)} - onClick={() => - props.onFileClick?.({ - ...row().node, - path: row().node.originalPath, - absolute: row().node.originalPath, - }) - } + onClick={() => selectFile(row().node, props.onFileClick)} + onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} > 0}> @@ -239,17 +276,13 @@ export default function FileTreeV2(props: { class="relative" onFocus={() => setFocused(row().node.path)} onBlur={() => setFocused(undefined)} - aria-expanded={file.tree.state(row().node.path)?.expanded ?? true} - onClick={() => - file.tree.state(row().node.path)?.expanded === false - ? file.tree.expand(row().node.path, { list: false }) - : file.tree.collapse(row().node.path) - } + aria-expanded={expanded(row().node.path)} + onClick={() => toggleDirectory(row().node.path, row().node.originalPath)} >
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 0a042c80af87..cb1c08221785 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -18,6 +18,7 @@ import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import FileTree from "@/components/file-tree" +import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" import { SessionContextUsage } from "@/components/session-context-usage" const reviewTabID = "session-side-panel-review-tab" @@ -107,11 +108,9 @@ export function SessionSidePanel(props: { return "mix" as const } - const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") - const out = new Map() for (const diff of diffs()) { - const file = normalize(diff.file) + const file = normalizeFileTreeV2Path(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) @@ -226,7 +225,6 @@ export function SessionSidePanel(props: { const active = activeTab() return active !== "review" && active !== "context" && active !== "empty" }) - const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix"))) const openFileKeybind = createMemo(() => command.keybindParts("file.open")) createEffect(() => { @@ -509,7 +507,7 @@ export function SessionSidePanel(props: { (browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB } active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")} - kinds={browserKinds()} + kinds={kinds()} state={props.fileBrowserState!} onSelect={(path) => previewTab(file.tab(path))} onSelectPermanent={(path) => openTab(file.tab(path))} diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index 8956e45b29ef..143b4f5f528a 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -65,6 +65,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { ) const searching = createMemo(() => props.state.filter().trim().length > 0) const kinds = createMemo(() => reviewDiffKinds(diffs())) + // Changes-only trees omit "M" — every row is already a change; A/D stay visible. + const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix"))) const activeDiff = createMemo(() => { // A focused comment takes over the preview until the preview applies it and // clears the focus; the owner then persists the file as the active selection. @@ -123,7 +125,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { diffs={diffs} filteredFiles={filteredFiles} searching={searching} - kinds={kinds} + kinds={treeKinds} activeDiff={activeDiff} /> } diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx index fc9f037232ed..04ad1e3c8253 100644 --- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx +++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx @@ -3,7 +3,7 @@ import { createQuery } from "@tanstack/solid-query" import { Icon } from "@opencode-ai/ui/icon" import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2" import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2" -import FileTree, { type Kind } from "@/components/file-tree" +import FileTreeV2, { type Kind } from "@/components/file-tree-v2" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" @@ -111,9 +111,7 @@ export function SessionFileBrowserTab(props: { props.onSelect(node.path)} diff --git a/packages/ui/src/v2/components/file-tree-v2.css b/packages/ui/src/v2/components/file-tree-v2.css index 61f6bfe3f747..ba88d09228c6 100644 --- a/packages/ui/src/v2/components/file-tree-v2.css +++ b/packages/ui/src/v2/components/file-tree-v2.css @@ -21,6 +21,7 @@ color: var(--v2-text-text-muted); text-align: left; cursor: pointer; + scroll-margin-block: 8px; transition: background-color 120ms ease, color 120ms ease; @@ -43,6 +44,21 @@ background-color: var(--v2-overlay-simple-overlay-pressed); } +[data-component="file-tree-v2"] [data-slot="file-tree-v2-guide"] { + position: absolute; + top: 0; + margin-top: -2px; + height: calc(100% + 2px); + width: 1px; + pointer-events: none; + background-color: var(--v2-border-border-muted); + opacity: 0; +} + +[data-component="file-tree-v2"]:hover [data-slot="file-tree-v2-guide"] { + opacity: 0.5; +} + [data-component="file-tree-v2"] .filetree-icon--mono { color: var(--v2-icon-icon-muted); } @@ -109,7 +125,7 @@ } [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="modified"] { - opacity: 0; + color: var(--v2-state-fg-info); } [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="added"] { From 666ea192339d9025a00541c9a6dcbda9b504f73e Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:36:42 +0530 Subject: [PATCH 11/11] gate updates to v2 only --- packages/app/src/components/session/index.ts | 1 + .../session/session-sortable-tab-v2.tsx | 66 ++ .../session/session-sortable-tab.tsx | 14 +- packages/app/src/pages/session/file-tabs.tsx | 336 +++++++++- .../src/pages/session/session-side-panel.tsx | 628 ++++++++++++------ packages/ui/src/components/tabs.css | 126 ++-- 6 files changed, 906 insertions(+), 265 deletions(-) create mode 100644 packages/app/src/components/session/session-sortable-tab-v2.tsx diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index 8e424f0f36f7..9e44cea17be6 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -1,6 +1,7 @@ export { SessionHeader } from "./session-header" export { SessionContextTab } from "./session-context-tab" export { SortableTab, FileVisual } from "./session-sortable-tab" +export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { NewSessionView } from "./session-new-view" export { NewSessionDesignView } from "./session-new-design-view" diff --git a/packages/app/src/components/session/session-sortable-tab-v2.tsx b/packages/app/src/components/session/session-sortable-tab-v2.tsx new file mode 100644 index 000000000000..158eba9f0b68 --- /dev/null +++ b/packages/app/src/components/session/session-sortable-tab-v2.tsx @@ -0,0 +1,66 @@ +import { createMemo, Show } from "solid-js" +import type { JSX } from "solid-js" +import { useSortable } from "@dnd-kit/solid/sortable" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { Tabs } from "@opencode-ai/ui/tabs" +import { useFile } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" +import { FileVisual } from "./session-sortable-tab" + +export function SortableTabV2(props: { + tab: string + index: () => number + temporary?: boolean + onTabClose: (tab: string) => void + onTabDoubleClick?: (tab: string) => void +}): JSX.Element { + const file = useFile() + const language = useLanguage() + const command = useCommand() + const sortable = useSortable({ + get id() { + return props.tab + }, + get index() { + return props.index() + }, + }) + const path = createMemo(() => file.pathFromTab(props.tab)) + const content = createMemo(() => { + const value = path() + if (!value) return + return + }) + return ( +
+
+ + props.onTabClose(props.tab)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => props.onTabClose(props.tab)} + onDblClick={() => props.onTabDoubleClick?.(props.tab)} + > + {(value) => value()} + +
+
+ ) +} diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index 39f505370f8b..d78f392941ac 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -1,6 +1,6 @@ import { createMemo, Show } from "solid-js" import type { JSX } from "solid-js" -import { useSortable } from "@dnd-kit/solid/sortable" +import { createSortable } from "@thisbeyond/solid-dnd" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" @@ -31,7 +31,6 @@ export function FileVisual(props: { path: string; active?: boolean; temporary?: export function SortableTab(props: { tab: string - index: () => number temporary?: boolean onTabClose: (tab: string) => void onTabDoubleClick?: (tab: string) => void @@ -39,14 +38,7 @@ export function SortableTab(props: { const file = useFile() const language = useLanguage() const command = useCommand() - const sortable = useSortable({ - get id() { - return props.tab - }, - get index() { - return props.index() - }, - }) + const sortable = createSortable(props.tab) const path = createMemo(() => file.pathFromTab(props.tab)) const content = createMemo(() => { const value = path() @@ -54,7 +46,7 @@ export function SortableTab(props: { return }) return ( -
+
event.stopPropagation()} onClick={(event) => event.stopPropagation()}> + + + + + + {props.editLabel} + + + {props.deleteLabel} + + + + +
+ ) +} + +function FileCommentMenuV2(props: { + moreLabel: string + editLabel: string + deleteLabel: string + onEdit: VoidFunction + onDelete: VoidFunction }) { return (
event.stopPropagation()} onClick={(event) => event.stopPropagation()}> @@ -44,6 +81,7 @@ function FileCommentMenu(props: { ) } + type ScrollPos = { x: number; y: number } function createScrollSync(input: { tab: () => string; view: ReturnType["view"] }) { @@ -171,6 +209,300 @@ export function FileTabContent(props: { tab: string }) { } export function SessionFileView(props: { tab: string }) { + const settings = useSettings() + return ( + }> + + + ) +} + +function SessionFileViewV1(props: { tab: string }) { + const file = useFile() + const comments = useComments() + const language = useLanguage() + const prompt = usePrompt() + const fileComponent = useFileComponent() + const { sessionKey, tabs, view } = useSessionLayout() + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + }).activeFileTab + + let find: FileSearchHandle | null = null + + const search = { + register: (handle: FileSearchHandle | null) => { + find = handle + }, + } + + const path = createMemo(() => file.pathFromTab(props.tab)) + const state = createMemo(() => { + const p = path() + if (!p) return + return file.get(p) + }) + const contents = createMemo(() => state()?.content?.content ?? "") + const cacheKey = createMemo(() => sampledChecksum(contents())) + const selectedLines = createMemo(() => { + const p = path() + if (!p) return null + if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null + return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null + }) + const scrollSync = createScrollSync({ + tab: () => props.tab, + view, + }) + + const selectionPreview = (source: string, selection: FileSelection) => { + return previewSelectedLines(source, { + start: selection.startLine, + end: selection.endLine, + }) + } + + const buildPreview = (filePath: string, selection: FileSelection) => { + const source = filePath === path() ? contents() : file.get(filePath)?.content?.content + if (!source) return undefined + return selectionPreview(source, selection) + } + + const addCommentToContext = (input: { + file: string + selection: SelectedLineRange + comment: string + preview?: string + origin?: "review" | "file" + }) => { + const selection = selectionFromLines(input.selection) + const preview = input.preview ?? buildPreview(input.file, selection) + + const saved = comments.add({ + file: input.file, + selection: input.selection, + comment: input.comment, + }) + prompt.context.add({ + type: "file", + path: input.file, + selection, + comment: input.comment, + commentID: saved.id, + commentOrigin: input.origin, + preview, + }) + } + + const updateCommentInContext = (input: { + id: string + file: string + selection: SelectedLineRange + comment: string + }) => { + comments.update(input.file, input.id, input.comment) + const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined + prompt.context.updateComment(input.file, input.id, { + comment: input.comment, + ...(preview ? { preview } : {}), + }) + } + + const removeCommentFromContext = (input: { id: string; file: string }) => { + comments.remove(input.file, input.id) + prompt.context.removeComment(input.file, input.id) + } + + const fileComments = createMemo(() => { + const p = path() + if (!p) return [] + return comments.list(p) + }) + + const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) + + const [note, setNote] = createStore({ + openedComment: null as string | null, + commenting: null as SelectedLineRange | null, + selected: null as SelectedLineRange | null, + }) + + const syncSelected = (range: SelectedLineRange | null) => { + const p = path() + if (!p) return + file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null) + } + + const activeSelection = () => note.selected ?? selectedLines() + + const commentsUi = createLineCommentController({ + comments: fileComments, + label: language.t("ui.lineComment.submit"), + draftKey: () => path() ?? props.tab, + mention: { + items: file.searchFilesAndDirectories, + }, + state: { + opened: () => note.openedComment, + setOpened: (id) => setNote("openedComment", id), + selected: () => note.selected, + setSelected: (range) => setNote("selected", range), + commenting: () => note.commenting, + setCommenting: (range) => setNote("commenting", range), + syncSelected, + hoverSelected: syncSelected, + }, + getHoverSelectedRange: activeSelection, + cancelDraftOnCommentToggle: true, + clearSelectionOnSelectionEndNull: true, + onSubmit: ({ comment, selection }) => { + const p = path() + if (!p) return + addCommentToContext({ file: p, selection, comment, origin: "file" }) + }, + onUpdate: ({ id, comment, selection }) => { + const p = path() + if (!p) return + updateCommentInContext({ id, file: p, selection, comment }) + }, + onDelete: (comment) => { + const p = path() + if (!p) return + removeCommentFromContext({ id: comment.id, file: p }) + }, + editSubmitLabel: language.t("common.save"), + renderCommentActions: (_, controls) => ( + + ), + }) + + createEffect(() => { + if (typeof window === "undefined") return + + const onKeyDown = (event: KeyboardEvent) => { + if (activeFileTab() !== props.tab) return + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return + if (event.key.toLowerCase() !== "f") return + + event.preventDefault() + event.stopPropagation() + find?.focus() + } + + makeEventListener(window, "keydown", onKeyDown, { capture: true }) + }) + + createEffect( + on( + path, + () => { + commentsUi.note.reset() + }, + { defer: true }, + ), + ) + + createEffect(() => { + const focus = comments.focus() + const p = path() + if (!focus || !p) return + if (focus.file !== p) return + if (activeFileTab() !== props.tab) return + + const target = fileComments().find((comment) => comment.id === focus.id) + if (!target) return + + commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true }) + requestAnimationFrame(() => comments.clearFocus()) + }) + + let prev = { + loaded: false, + ready: false, + active: false, + } + + createEffect(() => { + const loaded = !!state()?.loaded + const ready = file.ready() + const active = activeFileTab() === props.tab + const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active) + prev = { loaded, ready, active } + if (!restore) return + scrollSync.queueRestore() + }) + + const renderFile = (source: string) => ( +
+ { + scrollSync.queueRestore() + }} + annotations={commentsUi.annotations()} + renderAnnotation={commentsUi.renderAnnotation} + renderGutterUtility={commentsUi.renderGutterUtility} + onLineSelected={(range: SelectedLineRange | null) => { + commentsUi.onLineSelected(range) + }} + onLineSelectionEnd={(range: SelectedLineRange | null) => { + commentsUi.onLineSelectionEnd(range) + }} + search={search} + class="select-text" + media={{ + mode: "auto", + path: path(), + current: state()?.content, + onLoad: scrollSync.queueRestore, + onError: (args: { kind: "image" | "audio" | "svg" }) => { + if (args.kind !== "svg") return + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + }) + }, + }} + /> +
+ ) + + const content = () => ( +
+ + + {renderFile(contents())} + +
{language.t("common.loading")}...
+
+ {(err) =>
{err()}
}
+
+
+
+ ) + + return content() +} + +function SessionFileViewV2(props: { tab: string }) { const file = useFile() const comments = useComments() const language = useLanguage() @@ -325,7 +657,7 @@ export function SessionFileView(props: { tab: string }) { }, editSubmitLabel: language.t("common.save"), renderCommentActions: (_, controls) => ( - command.keybindParts("file.open")) + const [store, setStore] = createStore({ + activeDraggable: undefined as string | undefined, + }) + + const handleDragStart = (event: unknown) => { + const id = getDraggableId(event) + if (!id) return + setStore("activeDraggable", id) + } + + const handleDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + const currentTabs = tabs().all() + const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString()) + if (toIndex === undefined) return + tabs().move(draggable.id.toString(), toIndex) + } + + const handleDragEnd = () => { + setStore("activeDraggable", undefined) + } createEffect(() => { if (!file.ready()) return @@ -291,100 +325,42 @@ export function SessionSidePanel(props: { "bg-background-base": !settings.general.newLayoutDesigns(), }} > - - event.target instanceof Element && - (!!event.target.closest('[data-slot="tabs-trigger-close-button"]') || - !!event.target.closest(".session-review-v2-open-in-app-slot")), - }), - ]} - modifiers={[ - RestrictToHorizontalAxis, - RestrictToElement.configure({ element: () => tabList ?? null }), - ]} - plugins={(defaults) => [ - ...defaults.filter((plugin) => plugin !== Accessibility), - AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), - Feedback.configure({ dropAnimation: null }), - ]} - onDragEnd={(event) => { - const source = event.operation.source - if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return - tabs().move(source.id.toString(), source.index) - }} - > - -
- { - tabList = el - const stop = createFileTabListSync({ el, contextOpen }) - onCleanup(stop) - }} - > - - {(toggle) => ( -
{toggle()()}
- )} -
- - + + + +
+ { + const stop = createFileTabListSync({ el, contextOpen }) + onCleanup(stop) + }} > - {props.hasReview() - ? language.t("session.review.filesChanged", { count: props.reviewCount() }) - : language.t("session.tab.review")} - - - - + - tabs().close("context")} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > -
- -
{language.t("session.tab.context")}
-
-
-
- - {(tab) => ( - tabs().all().indexOf(tab)} - temporary={temporaryTab() === tab} - onTabClose={tabs().close} - onTabDoubleClick={temporaryTab() === tab ? openTab : undefined} - /> - } - > +
+
{language.t("session.tab.review")}
+ +
{props.reviewCount()}
+
+
+ +
+ tabs().close(SESSION_OPEN_FILE_TAB)} + onClick={() => tabs().close("context")} aria-label={language.t("common.closeTab")} /> } hideCloseButton - onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} + onMiddleClick={() => tabs().close("context")} > -
- - {language.t("command.file.open")} +
+ +
{language.t("session.tab.context")}
- )} - -
+ + {(tab) => ( + + } + > + + tabs().close(SESSION_OPEN_FILE_TAB)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} + > +
+ + {language.t("command.file.open")} +
+
+
+ )} +
+ +
+ + { + void import("@/components/dialog-select-file").then((x) => { + dialog.show(() => ) + }) + }} + aria-label={language.t("command.file.open")} + /> + +
+ +
+ + +
+ {props.reviewPanel()} +
+
+ + + +
+
+ +
+ {language.t("session.files.selectToOpen")} +
+
+
+
+
+ + + +
+ +
+
+
+ + + {(tab) => } + + + + + {(tab) => { + const path = file.pathFromTab(tab) + return ( +
+ + {(p) => } + +
+ ) + }} +
+
+ + } + > + + event.target instanceof Element && + (!!event.target.closest('[data-slot="tabs-trigger-close-button"]') || + !!event.target.closest(".session-review-v2-open-in-app-slot")), + }), + ]} + modifiers={[ + RestrictToHorizontalAxis, + RestrictToElement.configure({ element: () => tabList ?? null }), + ]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + const source = event.operation.source + if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return + tabs().move(source.id.toString(), source.index) + }} + > + +
+ { + tabList = el + const stop = createFileTabListSync({ el, contextOpen }) + onCleanup(stop) }} > - - {language.t("command.file.open")} - 0}> - - - - } - placement="bottom" - class="flex items-center" - > - } - variant="ghost-muted" - size="large" - onClick={() => { - if (props.fileBrowserState) { - openFileBrowser() - return + + {(toggle) => ( +
{toggle()()}
+ )} +
+ + + {props.hasReview() + ? language.t("session.review.filesChanged", { count: props.reviewCount() }) + : language.t("session.tab.review")} + + + + + tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
+
+ + {(tab) => ( + tabs().all().indexOf(tab)} + temporary={temporaryTab() === tab} + onTabClose={tabs().close} + onTabDoubleClick={temporaryTab() === tab ? openTab : undefined} + /> } - void import("@/components/dialog-select-file").then((x) => { - dialog.show(() => ) - }) - }} - aria-label={language.t("command.file.open")} - /> -
+ > + + tabs().close(SESSION_OPEN_FILE_TAB)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} + > +
+ + {language.t("command.file.open")} +
+
+ + )} + +
+ + {language.t("command.file.open")} + 0}> + + + + } + placement="bottom" + class="flex items-center" + > + } + variant="ghost-muted" + size="large" + onClick={() => openFileBrowser()} + aria-label={language.t("command.file.open")} + /> + +
+
+
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > +
- -
event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - > -
-
- - -
- {props.reviewPanel()} -
-
- - - -
-
- -
- {language.t("session.files.selectToOpen")} + + +
+ {props.reviewPanel()} +
+
+ + + +
+
+ +
+ {language.t("session.files.selectToOpen")} +
-
- - + + - - -
- -
-
-
- - -
- previewTab(file.tab(path))} - onSelectPermanent={(path) => openTab(file.tab(path))} - filterRef={(element) => (fileFilter = element)} - /> -
-
+ + +
+ +
+
+
- - {(tab) => } - - - + +
+ previewTab(file.tab(path))} + onSelectPermanent={(path) => openTab(file.tab(path))} + filterRef={(element) => (fileFilter = element)} + /> +
+
+ + +
@@ -548,7 +760,21 @@ export function SessionSidePanel(props: { > - {language.t("session.review.filesChanged", { count: props.reviewCount() })} + + {props.reviewCount()}{" "} + {language.t( + props.reviewCount() === 1 + ? "session.review.change.one" + : "session.review.change.other", + )} + + } + > + {language.t("session.review.filesChanged", { count: props.reviewCount() })} + {language.t("session.files.all")} diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index de859f5e7d28..9df9cbd83dca 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -202,6 +202,11 @@ transform 120ms ease; } + &[data-value="review"] { + padding-left: 8px; + padding-right: 8px; + } + [data-slot="tabs-trigger"] { height: 100%; padding: 0 !important; @@ -263,7 +268,6 @@ } } - #review-panel &[data-variant="normal"][data-orientation="horizontal"], #terminal-panel &[data-variant="normal"][data-orientation="horizontal"] { [data-slot="tabs-list"] { height: 52px; @@ -296,7 +300,6 @@ } } - [data-slot="tabs-trigger"], [data-slot="terminal-tab-title"] { font-family: Inter, sans-serif; font-style: normal; @@ -308,44 +311,6 @@ font-variation-settings: "slnt" 0; font-variant-numeric: tabular-nums; } - - /* Preview/temporary tabs: beat trigger + .text-14-medium upright defaults */ - [data-slot="tabs-trigger"] .italic { - font-style: italic !important; - font-variation-settings: "slnt" -10 !important; - } - } - - #review-panel &[data-variant="normal"][data-orientation="horizontal"] { - [data-slot="tabs-list"] { - --tabs-review-fade: 8px; - } - - [data-slot="tabs-trigger-wrapper"] { - padding-inline: 10px; - - &:has([data-slot="tabs-trigger-close-button"]) { - padding-right: 10px; - } - } - - [data-slot="tabs-trigger-wrapper"]:has([data-selected]) { - background-color: var(--v2-background-bg-layer-02); - } - - [data-slot="tabs-trigger"] { - color: var(--v2-text-text-muted); - - &[data-selected] { - color: var(--v2-text-text-base); - } - - .tab-fileicon-color, - .tab-fileicon-mono, - [data-slot="icon-svg"] { - margin-left: -2px; - } - } } &[data-variant="alt"] { @@ -734,17 +699,80 @@ body[data-new-layout] #terminal-panel [data-component="tabs"][data-variant="norm } } -#review-panel [data-component="tabs"] .session-review-v2-tabs-bar { +body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] { + [data-slot="tabs-list"] { + height: 52px; + padding-right: 12px; + gap: 8px; + --tabs-review-fade: 8px; + } + + [data-slot="tabs-trigger-wrapper"] { + height: 28px; + padding-inline: 10px; + 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: 10px; + } + + &:has([data-selected]) { + background-color: var(--v2-background-bg-layer-02); + box-shadow: none; + } + } + + [data-slot="tabs-trigger"] { + 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-muted); + font-variation-settings: "slnt" 0; + font-variant-numeric: tabular-nums; + + &[data-selected] { + color: var(--v2-text-text-base); + } + + .tab-fileicon-color, + .tab-fileicon-mono, + [data-slot="icon-svg"] { + margin-left: -2px; + } + } + + /* Preview/temporary tabs: beat trigger + .text-14-medium upright defaults */ + [data-slot="tabs-trigger"] .italic { + font-style: italic !important; + font-variation-settings: "slnt" -10 !important; + } +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar { width: 100%; min-width: 0; gap: 8px; box-sizing: border-box; border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); - background-color: var(--background-stronger, var(--v2-background-bg-base)); + background-color: var(--v2-background-bg-base); } -#review-panel [data-component="tabs"] .session-review-v2-tabs-bar [data-slot="tabs-list"], -#review-panel +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar [data-slot="tabs-list"], +body[data-new-layout] + #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] .session-review-v2-tabs-bar [data-slot="tabs-list"] { @@ -754,19 +782,15 @@ body[data-new-layout] #terminal-panel [data-component="tabs"][data-variant="norm border-bottom: none; } -#review-panel [data-component="tabs"] .session-review-v2-tabs-bar [data-slot="tabs-list"] > .sticky { +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar [data-slot="tabs-list"] > .sticky { padding-right: 0; } -#review-panel [data-component="tabs"] .session-review-v2-open-in-app-slot { +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app-slot { flex-shrink: 0; margin-left: auto; } -#review-panel [data-component="tabs"] .session-review-v2-open-in-app { +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app { flex-shrink: 0; } - -body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar { - background-color: var(--v2-background-bg-base); -}