feat(desktop): move review controls into their panels (#51312)

Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
usrnk1
2026-10-05 05:18:48 +02:00
committed by GitHub
Unverified
parent c542a43503
commit e86ec98711
17 changed files with 252 additions and 100 deletions
@@ -833,6 +833,32 @@ test("a chosen mode the session stops offering shows as Git, and comes back when
await expect(file).toHaveText("beta.ts")
})
test("names a single change source and keeps the review header rows at 48px", async ({ page }) => {
// Turn changes are offered only for Git, so another VCS offers one source.
await openSession(page, { name: "ReviewHeader", project: { vcs: "hg" }, vcsDiff: [fileDiff("src/alpha.ts")] })
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
const panel = page.locator("#review-panel")
const toggle = panel.getByRole("button", { name: "Toggle file tree" })
const header = panel.locator('[data-slot="session-review-v2-sidebar-header"]')
const toolbar = panel.locator('[data-slot="session-review-v2-toolbar"]')
await expect(header.getByText("Git changes", { exact: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(header).toHaveCSS("height", "48px")
await expect(toolbar).toHaveCSS("height", "48px")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toolbar.getByText("Git changes", { exact: true })).toBeVisible()
await expect(toolbar).toHaveCSS("height", "48px")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(header).toHaveCSS("height", "48px")
await expect(toolbar).toHaveCSS("height", "48px")
})
for (const direction of ["ltr", "rtl"] as const) {
test(`review toggle stays at the header edge in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 1000, height: 900 })
@@ -14,7 +14,6 @@ import { Mark } from "@opencode/ui/logo"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { Menu } from "@opencode/ui/menu"
import { SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
import {
MenuItem,
type MountedSession,
@@ -160,13 +159,6 @@ export function SideRegion(props: {
onCleanup(createTabStripScroll({ el, lead: props.region.lead }))
}}
>
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<SessionReviewV2SidebarToggle
opened={props.sidebar.opened()}
disabled={props.region.selected()?.tab.sidebar === "locked"}
onToggle={props.sidebar.toggle}
/>
</div>
<For each={props.region.keys()}>
{(key) => (
<Show when={props.region.entry(key)}>
+15 -3
View File
@@ -3,7 +3,7 @@ import { createStore } from "solid-js/store"
import { createQuery, keepPreviousData } from "@tanstack/solid-query"
import { Icon } from "@opencode/ui/icon"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar } from "@opencode/session-ui/v2/session-review-v2"
import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
import { getFilename } from "@opencode/util/path"
import type { ChangeKind } from "../review/contract"
import {
@@ -104,12 +104,24 @@ export function SessionFileBrowserTab(props: {
// unmounts the whole panel on every file-tab switch and resets sidebar scroll.
return (
<SessionFilePanelV2
toolbar={false}
toolbar={!props.mobile && !props.placeholder && !sidebarOpened()}
toolbarStart={<span class="truncate font-[530]">{title()}</span>}
sidebarCollapsed={!sidebarOpened()}
sidebarToggleGap={10}
sidebarToggle={
props.mobile ? undefined : (
<SessionReviewV2SidebarToggle
opened={sidebarOpened()}
disabled={props.placeholder}
onToggle={props.state.toggle}
/>
)
}
sidebar={
<SessionReviewV2Sidebar
open={sidebarOpened()}
transition={props.state.transition()}
title={<span class="truncate">{title()}</span>}
title={<span class="truncate font-[530]">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
onFilterKeyDown={onFilterKeyDown}
@@ -6,6 +6,12 @@ import type { OpenApp } from "./apps"
export type TreeTab = "changes" | "all"
/** An open-in-app request in flight: the app it opens, or undefined while none is. */
export interface OpenRequest {
app(): OpenApp | undefined
set(app: OpenApp | undefined): void
}
/** Per-window file state that setup owns and its lazily loaded views share. */
export interface FileShared {
readonly changes: Accessor<Live<Changes>>
@@ -22,6 +28,8 @@ export interface FileShared {
readonly installed: Map<string, Promise<boolean>>
/** The open-in-app choice. Desktop only. */
readonly app?: { current(): OpenApp; set(app: OpenApp): void }
/** The "Open in" buttons' request in flight, shared so the tab strip's and a panel header's agree across tab switches. */
readonly request: OpenRequest
/** The last selection of each file tab, readable before a session's file view state loads. */
readonly handoff: {
get(session: string, path: string): LineRange | null | undefined
@@ -32,3 +32,17 @@ export interface FileTree {
}
export const FileTree = Contract.define<FileTree, "file.tree">("file.tree")
export interface OpenInAppProps {
/** The session screen whose workspace opens. */
readonly screen: SessionScreen
readonly session: MountedSession
}
/** The desktop "Open in" button, for a panel header that shows it in place of the tab strip's. */
export interface OpenInApp {
/** Renders nothing on the web or for a remote server. While it is mounted, the tab strip leaves out its own. */
Button(props: OpenInAppProps): JSX.Element
}
export const OpenInApp = Contract.define<OpenInApp, "file.openInApp">("file.openInApp")
+2 -2
View File
@@ -3,14 +3,14 @@ import { Browser } from "../browser/contract"
import { Changes } from "../review/contract"
import { Extension, Store } from "../sdk"
import { OpenAppPreferences } from "./apps"
import { FileTree } from "./contract"
import { FileTree, OpenInApp } from "./contract"
import en from "./i18n/en"
const TreeState = Schema.Struct({ tab: Schema.Literals(["changes", "all"]) })
export default Extension.define({
id: "file",
provides: { tree: FileTree },
provides: { tree: FileTree, openIn: OpenInApp },
// Without review the tree lists only the workspace files; without the browser, HTML opens as a file tab.
uses: { changes: Changes, browser: Browser },
stores: {
@@ -9,7 +9,7 @@ import { showToast } from "@opencode/ui/toast"
import { Tooltip } from "@opencode/ui/tooltip"
import { createLatest, useExtension, type Context, type OS, type MountedSession, type SessionScreen } from "../sdk"
import type { OpenApp } from "./apps"
import { useShared } from "./context"
import { useShared, type OpenRequest } from "./context"
import { openInAppParentPath } from "./path"
const MAC_OPEN_APPS = [
@@ -68,7 +68,7 @@ const showRequestError = (ctx: Context, err: Error | string) => {
})
}
export function useOpenInApp(input: { session: MountedSession; path: () => string }) {
export function useOpenInApp(input: { session: MountedSession; path: () => string; request?: OpenRequest }) {
const ctx = useExtension()
const desktop = ctx.desktop
const shared = useShared()
@@ -110,7 +110,9 @@ export function useOpenInApp(input: { session: MountedSession; path: () => strin
})
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore<{ app?: OpenApp }>({})
const [local, setLocal] = createStore<{ app?: OpenApp }>({})
const request = input.request ?? { app: () => local.app, set: (app: OpenApp | undefined) => setLocal("app", app) }
const canOpen = createMemo(() => !!desktop && input.session.server.local)
@@ -121,7 +123,7 @@ export function useOpenInApp(input: { session: MountedSession; path: () => strin
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
const opening = createMemo(() => openRequest.app !== undefined)
const opening = createMemo(() => request.app() !== undefined)
const selectApp = (app: OpenApp | "finder") => {
if (!options().some((item) => item.id === app)) return
@@ -135,19 +137,19 @@ export function useOpenInApp(input: { session: MountedSession; path: () => strin
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
request.set(app)
const request =
const launched =
app === "finder" && reveal
? desktop
.reveal(target)
.then((revealed) => (revealed ? undefined : desktop.launch(openInAppParentPath(target))))
: desktop.launch(target, openWith)
request
launched
.catch((err) => showRequestError(ctx, err))
.finally(() => {
setOpenRequest("app", undefined)
request.set(undefined)
})
}
@@ -187,7 +189,7 @@ type OpenInAppState = ReturnType<typeof useOpenInApp>
export default function OpenInAppButton(props: { session: MountedSession; screen: SessionScreen }) {
const ctx = useExtension()
const directory = () => props.screen.file.root
const state = useOpenInApp({ session: props.session, path: directory })
const state = useOpenInApp({ session: props.session, path: directory, request: useShared().request })
return (
<Show when={directory() && state.canOpen()}>
+63 -10
View File
@@ -1,4 +1,16 @@
import { batch, createMemo, lazy, on, onCleanup, Show, Suspense, type ParentProps } from "solid-js"
import {
batch,
createMemo,
createSignal,
lazy,
on,
onCleanup,
Show,
Suspense,
type ParentProps,
type Signal,
} from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode/ui/icon"
import { encodeFilePath, getFilename } from "@opencode/util/path"
import {
@@ -22,7 +34,8 @@ import {
import { artifactKind } from "@opencode/util/artifact"
import { resolveArtifactPath } from "./artifact"
import { FileContext, type FileShared } from "./context"
import { FileTree } from "./contract"
import type { OpenApp } from "./apps"
import { FileTree, OpenInApp } from "./contract"
import type File from "./index"
import { FileVisual } from "./label"
import { fileTabId, fileTabPath, isFileTab, workspaceFileUrl } from "./path"
@@ -48,6 +61,7 @@ const setup: Setup<typeof File> = (ctx) => {
const tree = ctx.stores.tree
const [handoff, setHandoff] = storage.memory<Handoff>("handoff", { initial: { sessions: {} } })
const preference = desktop ? ctx.stores.app : undefined
const [request, setRequest] = createStore<{ app?: OpenApp }>({})
// Tab objects per session screen, which stays while it routes another session, reused so neither strip updates nor a
// session switch rebuild a trigger. `close` prunes them.
@@ -105,6 +119,10 @@ const setup: Setup<typeof File> = (ctx) => {
draft.app = app
}),
},
request: {
app: () => request.app,
set: (app) => setRequest("app", app),
},
handoff: {
get: (session, path) => handoff.sessions[session]?.[path],
set: (session, files) =>
@@ -166,7 +184,6 @@ const setup: Setup<typeof File> = (ctx) => {
draggable: false,
closable: "hover",
transient: true,
sidebar: "locked",
group: GROUP,
dom: { panel: TABPANEL },
}
@@ -286,22 +303,58 @@ const setup: Setup<typeof File> = (ctx) => {
},
})
if (desktop) {
const OpenInAppButton = lazy(() => import("./open-in-app"))
const OpenInAppButton = lazy(() => import("./open-in-app"))
// How many "Open in" buttons each screen's panel headers show; the tab strip shows its own only while none do.
const headers = new WeakMap<SessionScreen, Signal<number>>()
const headerButtons = (screen: SessionScreen) => {
const existing = headers.get(screen)
if (existing) return existing
const created = createSignal(0)
headers.set(screen, created)
return created
}
if (desktop) {
onCleanup(onIdle(() => void OpenInAppButton.preload()))
ctx.add(Slot, {
at: "session.panel.end",
render: (input) => (
<FileProvider>
<Suspense>
<OpenInAppButton session={input.session} screen={input.screen} />
</Suspense>
</FileProvider>
<Show when={headerButtons(input.screen)[0]() === 0}>
<FileProvider>
<Suspense>
<OpenInAppButton session={input.session} screen={input.screen} />
</Suspense>
</FileProvider>
</Show>
),
})
}
ctx.provide(OpenInApp, {
Button: bindExtension((props) => {
const count = headerButtons(props.screen)
count[1]((value) => value + 1)
onCleanup(() => count[1]((value) => value - 1))
return (
<Show when={desktop}>
<FileProvider>
<Suspense>
<OpenInAppButton session={props.session} screen={props.screen} />
</Suspense>
</FileProvider>
</Show>
)
}),
})
ctx.add(Slot, {
at: "session.panel.sidebar",
render: (input) => (
+4 -3
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { FileTree } from "../file/contract"
import { FileTree, OpenInApp } from "../file/contract"
import { Extension, Store } from "../sdk"
import { Changes } from "./contract"
import en from "./i18n/en"
@@ -19,8 +19,9 @@ const SessionState = Schema.Struct({
export default Extension.define({
id: "review",
provides: { changes: Changes },
// The changed files list in the file browser's tree; without it the list stays empty.
uses: { tree: FileTree },
// The changed files list in the file browser's tree; without it the list stays empty. The toolbar also shows the
// file extension's "Open in" button.
uses: { tree: FileTree, openIn: OpenInApp },
stores: {
diff: Store.global(
DiffState,
@@ -6,6 +6,7 @@ import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
SessionReviewV2,
SessionReviewV2Sidebar,
SessionReviewV2SidebarToggle,
type SessionReviewExpandMode,
} from "@opencode/session-ui/v2/session-review-v2"
import { SessionReviewFilePreviewV2 } from "@opencode/session-ui/v2/session-review-file-preview-v2"
@@ -111,6 +112,14 @@ export default function ReviewPanelContent(props: {
}
function ReviewPanel(props: ReviewPanelProps) {
const ctx = useExtension<typeof Review>()
const openIn = () => {
const live = ctx.uses.openIn()
return live.status === "active" ? live.value : undefined
}
const readFile = async (path: string) =>
props.session.server.client.file
.read({ path, location: { directory: props.screen.file.root } })
@@ -188,6 +197,14 @@ function ReviewPanel(props: ReviewPanelProps) {
stats={<DiffChanges changes={diffs()} />}
empty={props.empty}
sidebarOpen={props.state.sidebar.opened()}
sidebarToggle={
<SessionReviewV2SidebarToggle opened={props.state.sidebar.opened()} onToggle={props.state.sidebar.toggle} />
}
toolbarAction={
<Show when={openIn()}>
{(openIn) => <Dynamic component={openIn().Button} screen={props.screen} session={props.session} />}
</Show>
}
sidebar={
// Always mounted: the sidebar header hosts the changes-mode dropdown,
// which must stay reachable when the current mode has zero diffs.
+28 -8
View File
@@ -7,21 +7,36 @@ import type { ChangeMode, ReviewModel } from "./model"
export function ReviewTitle(props: { review: ReviewModel }) {
const ctx = useExtension()
const label = (option: ChangeMode) => {
if (option === "git") return ctx.t("ui.sessionReview.title.git")
if (option === "branch") return ctx.t("ui.sessionReview.title.branch")
return ctx.t("ui.sessionReview.title.lastTurn")
}
return (
<Show when={props.review.canReview() && props.review.options().length > 0}>
<Select
options={props.review.options()}
current={props.review.mode()}
label={label}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && props.review.setMode(option)}
/>
<Show
when={props.review.options().length === 1 && props.review.options()[0]}
fallback={
<Select
options={props.review.options()}
current={props.review.mode()}
label={label}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && props.review.setMode(option)}
/>
}
>
{(only) => (
<span class="inline-flex h-6 items-center ps-2 pe-1 text-[13px] leading-[var(--line-height-compact)] font-[530] tracking-[-0.04px] text-v2-text-text-base">
{label(only())}
</span>
)}
</Show>
</Show>
)
}
@@ -29,11 +44,15 @@ export function ReviewTitle(props: { review: ReviewModel }) {
export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }) {
const ctx = useExtension()
const loading = () => !props.review.ready()
const text = () => {
if (props.review.mode() === "git") return ctx.t("empty.git")
if (props.review.mode() === "branch") return ctx.t("empty.branch")
return ctx.t("noChanges")
}
return (
<Switch>
<Match when={loading()}>
@@ -56,6 +75,7 @@ export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }
export function ReviewPanelEmpty(props: { review: ReviewModel }) {
const ctx = useExtension()
const loading = () => !props.review.ready()
return (
<Switch>
<Match when={loading()}>
@@ -186,8 +186,6 @@ export interface PanelTab {
readonly file?: string
/** The tab panel itself joins the tab order, for content without focusable elements. */
readonly tabbable?: boolean
/** `locked`: forces the panel's inner sidebar open and disables its toggle. */
readonly sidebar?: "locked"
/** Stable DOM ids for the trigger and the tab panel. */
readonly dom?: {
/** The trigger's id. */
+1 -1
View File
@@ -96,7 +96,7 @@ export interface PanelFrame {
readonly reserve: Accessor<boolean>
/** Plays size animations; false while the user drags a region edge. */
readonly animate: Accessor<boolean>
/** One inner sidebar preference shared by every side panel, toggled from the tab strip. */
/** One inner sidebar preference shared by every side panel; a panel with a sidebar renders its own toggle. */
readonly sidebar: PanelSidebar
/** This extension's tab ids stored in the session's side strip (the ids `list` receives as `open`). */
readonly open: Accessor<readonly string[]>
@@ -3,13 +3,22 @@ import "./session-review-v2.css"
export function SessionFilePanelV2(props: {
sidebar?: JSX.Element
sidebarToggle?: JSX.Element
sidebarToggleGap?: 2 | 10
sidebarCollapsed?: boolean
toolbar: boolean
toolbarStart?: JSX.Element
toolbarEnd?: JSX.Element
children?: JSX.Element
}) {
return (
<div data-component="session-review-v2">
<div
data-component="session-review-v2"
data-sidebar-toggle={props.sidebarToggle ? "" : undefined}
data-sidebar-toggle-gap={props.sidebarToggleGap}
data-sidebar-collapsed={props.sidebarCollapsed ? "" : undefined}
>
<div data-slot="session-review-v2-sidebar-toggle">{props.sidebarToggle}</div>
<div data-slot="session-review-v2-body">
{props.sidebar}
<div data-slot="session-review-v2-preview">
@@ -1,4 +1,5 @@
[data-component="session-review-v2"] {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
@@ -7,6 +8,13 @@
background: var(--v2-background-bg-base);
}
[data-component="session-review-v2"] [data-slot="session-review-v2-sidebar-toggle"] {
position: absolute;
inset-block-start: 9.5px;
inset-inline-start: 8px;
z-index: 2;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-body"] {
display: flex;
flex: 1;
@@ -68,6 +76,14 @@
flex-shrink: 0;
}
[data-component="session-review-v2"][data-sidebar-toggle] [data-slot="session-review-v2-sidebar-header"] {
box-sizing: border-box;
height: 48px;
padding-block: 0;
padding-inline-start: 46px;
border-bottom: 1px solid transparent;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-title"] {
display: flex;
align-items: center;
@@ -83,6 +99,10 @@
margin-inline-start: 8px;
}
[data-component="session-review-v2"][data-sidebar-toggle] [data-slot="session-review-v2-sidebar-title"] {
margin-inline-start: 0;
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-title"]
[data-component="select-v2-root"] {
@@ -231,16 +251,29 @@
}
[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar"] {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
padding-inline-start: 8px;
height: 48px;
padding-block: 0;
padding-inline: 8px 12px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
}
[data-component="session-review-v2"][data-sidebar-toggle][data-sidebar-collapsed]
[data-slot="session-review-v2-toolbar"] {
padding-inline-start: 46px;
}
[data-component="session-review-v2"][data-sidebar-toggle-gap="10"] [data-slot="session-review-v2-sidebar-header"],
[data-component="session-review-v2"][data-sidebar-toggle-gap="10"][data-sidebar-collapsed]
[data-slot="session-review-v2-toolbar"] {
padding-inline-start: 54px;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar-group"] {
display: flex;
align-items: center;
@@ -15,7 +15,9 @@ import { getWorkerPool } from "../../pierre/worker"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "./session-file-panel-v2"
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN = 200
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX = 480
export type SessionReviewExpandMode = "expand" | "collapse"
@@ -26,10 +28,12 @@ export type SessionReviewV2Props = {
empty?: JSX.Element
sidebarOpen?: boolean
sidebar?: JSX.Element
sidebarToggle?: JSX.Element
activeFile?: string
files: string[]
onSelectFile: (file: string) => void
diffStyle: SessionReviewDiffStyle
toolbarAction?: JSX.Element
onDiffStyleChange?: (style: SessionReviewDiffStyle) => void
expandMode: SessionReviewExpandMode
onExpandModeChange: (mode: SessionReviewExpandMode) => void
@@ -158,21 +162,26 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
const fileIndex = () => {
const files = props.files
if (files.length === 0) return -1
const active = props.activeFile
const i = active ? files.indexOf(active) : -1
if (i >= 0) return i
return 0
}
const prev = () => {
if (!canCycle()) return
return props.files[(fileIndex() - 1 + props.files.length) % props.files.length]
}
const next = () => {
if (!canCycle()) return
return props.files[(fileIndex() + 1) % props.files.length]
}
@@ -193,11 +202,15 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
// pane is mounted, but never while typing in an input or comment editor.
makeEventListener(document, "keydown", (event) => {
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return
if (event.key !== previousKey() && event.key !== nextKey()) return
const target = event.target
if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return
if (!props.hasDiffs || !canCycle()) return
const file = event.key === previousKey() ? prev() : next()
if (!file) return
event.preventDefault()
cycle(file)
@@ -265,6 +278,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
const toolbarEnd = () => (
<>
{props.toolbarAction}
<SegmentedControl
value={props.expandMode}
onChange={(value) => {
@@ -313,7 +327,9 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
return (
<SessionFilePanelV2
sidebar={props.sidebar}
toolbar={props.hasDiffs}
sidebarToggle={props.sidebarToggle}
sidebarCollapsed={props.sidebarOpen === false}
toolbar={props.hasDiffs || props.sidebarOpen === false}
toolbarStart={toolbarStart()}
toolbarEnd={toolbarEnd()}
>
+1 -50
View File
@@ -629,61 +629,12 @@
[data-component="tabs"]
.session-review-v2-tabs-bar
[data-slot="tabs-list"]
> .sticky:not(.session-review-v2-sidebar-toggle-slot) {
> .sticky {
left: auto;
right: auto;
inset-inline-end: 0;
}
:root
body
#review-panel
[data-component="tabs"]
.session-review-v2-tabs-bar
[data-slot="tabs-list"]
> .session-review-v2-sidebar-toggle-slot.sticky {
left: auto;
right: auto;
inset-inline-start: 0;
padding-inline-end: 0;
&::before {
content: "";
position: absolute;
top: 50%;
bottom: auto;
inset-inline-start: auto;
inset-inline-end: 100%;
transform: translateY(-50%);
width: 12px;
height: 28px;
background-color: var(--v2-background-bg-base);
background-image: none;
pointer-events: none;
}
&::after {
content: "";
position: absolute;
top: 50%;
inset-inline-start: 100%;
inset-inline-end: auto;
transform: translateY(-50%);
width: 8px;
height: 28px;
pointer-events: none;
background: linear-gradient(90deg, var(--v2-background-bg-base), transparent);
}
&:dir(rtl)::after {
background: linear-gradient(270deg, var(--v2-background-bg-base), transparent);
}
[data-component="icon-button-v2"]::after {
display: none;
}
}
:root body #review-panel [data-component="tabs"] .session-review-v2-open-in-app-slot {
flex-shrink: 0;
margin-inline-start: auto;