fix(app): unmount hidden session panes (#35628)

This commit is contained in:
Luke Parker 2026-07-09 09:44:02 +10:00 committed by GitHub
parent aa52d30d7f
commit d8a57ee15c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 291 additions and 311 deletions

View file

@ -168,8 +168,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveAttribute("aria-hidden", "true")
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_773, "action.yml")
@ -181,8 +181,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "true")
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_773, "action.yml")
await page.setViewportSize({ width: 1_000, height: 700 })

View file

@ -2198,7 +2198,7 @@ export default function Page() {
</Show>
</div>
<Show when={!newSessionDesign()}>
<Show when={!newSessionDesign() && desktopSidePanelOpen()}>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
@ -2217,13 +2217,8 @@ export default function Page() {
<Show when={newSessionDesign()}>
<Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
<div class="min-w-0 h-full flex flex-1 flex-col">
<Show when={isDesktop()}>
<div
classList={{
"min-h-0 flex-1": desktopV2ReviewOpen() || desktopFileTreeOpen(),
"size-0 shrink-0 overflow-hidden": !(desktopV2ReviewOpen() || desktopFileTreeOpen()),
}}
>
<Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
<div class="min-h-0 flex-1">
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}

View file

@ -81,7 +81,6 @@ export function SessionSidePanel(props: {
}),
)
const open = createMemo(() => reviewOpen() || fileOpen())
const rendered = createMemo<boolean>((previous) => previous || open(), false)
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
if (!open()) return "0px"
@ -164,10 +163,6 @@ export function SessionSidePanel(props: {
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const reviewContentRendered = createMemo<boolean>(
(previous) => previous || (reviewOpen() && activeTab() === "review"),
false,
)
const fileTreeTab = () => layout.fileTree.tab()
@ -272,233 +267,220 @@ export function SessionSidePanel(props: {
}}
style={{ width: panelWidth() }}
>
<Show when={rendered()}>
<Show when={open()}>
<div
class="size-full flex"
classList={{
"border-l border-border-weaker-base": !settings.general.newLayoutDesigns(),
}}
>
<div
aria-hidden={!reviewOpen()}
inert={!reviewOpen()}
class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base"
classList={{
"pointer-events-none": !reviewOpen(),
}}
>
<div class="size-full min-w-0 h-full bg-background-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={activateTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={panelTabs()}>
{(tab) => (
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTab
tab={tab}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
/>
}
>
<Tabs.Trigger
value={SESSION_OPEN_FILE_TAB}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
<Show when={reviewOpen()}>
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base">
<div class="size-full min-w-0 h-full bg-background-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={activateTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<div class="flex items-center gap-1.5 italic">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Tabs.Trigger>
</Show>
)}
</For>
</SortableProvider>
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => {
if (props.fileBrowserState) {
openFileBrowser()
return
}
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && reviewContentRendered()}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
aria-hidden={activeTab() !== "review"}
inert={activeTab() !== "review"}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
classList={{ hidden: activeTab() !== "review" }}
>
{props.reviewPanel()}
</div>
</Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={panelTabs()}>
{(tab) => (
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTab
tab={tab}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
/>
}
>
<Tabs.Trigger
value={SESSION_OPEN_FILE_TAB}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
>
<div class="flex items-center gap-1.5 italic">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Tabs.Trigger>
</Show>
)}
</For>
</SortableProvider>
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => {
if (props.fileBrowserState) {
openFileBrowser()
return
}
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
</Tabs.Content>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Tabs.Content>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Show>
</Tabs.Content>
</Show>
</Tabs.Content>
</Show>
<Show when={browserTab()}>
<SessionFileBrowserTab
tab={browserTab()!}
placeholder={browserTab() === SESSION_OPEN_FILE_TAB}
active={file.pathFromTab(browserTab()!)}
kinds={browserKinds()}
state={props.fileBrowserState!}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</Show>
<Show when={browserTab()}>
<SessionFileBrowserTab
tab={browserTab()!}
placeholder={browserTab() === SESSION_OPEN_FILE_TAB}
active={file.pathFromTab(browserTab()!)}
kinds={browserKinds()}
state={props.fileBrowserState!}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</Show>
<Show when={!props.fileBrowserState && activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
<Show when={!props.fileBrowserState && activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</div>
</div>
</div>
</Show>
<Show when={shown()}>
<Show when={fileOpen()}>
<div
id="file-tree-panel"
aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"pointer-events-none": !fileOpen(),
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
@ -526,45 +508,49 @@ export function SessionSidePanel(props: {
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show when={fileTreeTab() === "changes"}>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/>
</Show>
</Match>
</Switch>
</Tabs.Content>
</Show>
<Show when={fileTreeTab() === "all"}>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
modified={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Show>
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Match>
</Switch>
</Tabs.Content>
</Match>
</Switch>
</Tabs.Content>
</Show>
</Tabs>
</div>
<Show when={fileOpen()}>

View file

@ -74,62 +74,62 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
return (
<div data-component="session-review-v2-sidebar-root">
<aside
data-slot="session-review-v2-sidebar"
data-resizing={resizing() ? "" : undefined}
aria-hidden={!props.open}
inert={!props.open}
style={{ width: props.open ? `${width()}px` : "0px" }}
>
<div data-slot="session-review-v2-sidebar-header">
<div data-slot="session-review-v2-sidebar-title">{props.title}</div>
{props.stats}
</div>
<div data-slot="session-review-v2-sidebar-filter">
<TextInputV2
type="search"
value={props.filter}
onInput={(event) => props.onFilterChange(event.currentTarget.value)}
onKeyDown={props.onFilterKeyDown}
autofocus={props.filterAutofocus}
ref={props.filterRef}
role={props.filterControls ? "combobox" : undefined}
aria-autocomplete={props.filterControls ? "list" : undefined}
aria-controls={props.filterControls}
aria-activedescendant={props.filterActiveDescendant}
aria-expanded={props.filterControls ? props.filterExpanded : undefined}
showClearButton={props.filter.length > 0}
clearLabel={i18n.t("ui.list.clearFilter")}
onClearClick={() => props.onFilterChange("")}
placeholder={i18n.t("ui.sessionReviewV2.filterFiles")}
aria-label={i18n.t("ui.sessionReviewV2.filterFiles")}
leadingIcon={
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.25 12.25L10.0625 10.0625M11.0833 6.41667C11.0833 8.994 8.994 11.0833 6.41667 11.0833C3.83934 11.0833 1.75 8.994 1.75 6.41667C1.75 3.83934 3.83934 1.75 6.41667 1.75C8.994 1.75 11.0833 3.83934 11.0833 6.41667Z"
stroke="currentColor"
stroke-linecap="square"
/>
</svg>
}
/>
</div>
<ScrollView
data-slot="session-review-v2-sidebar-tree"
class="group/file-tree-v2"
thumbVisibility="scroll"
viewportRef={props.viewportRef}
<Show when={props.open}>
<aside
data-slot="session-review-v2-sidebar"
data-resizing={resizing() ? "" : undefined}
style={{ width: `${width()}px` }}
>
{props.children}
</ScrollView>
</aside>
<div data-slot="session-review-v2-sidebar-header">
<div data-slot="session-review-v2-sidebar-title">{props.title}</div>
{props.stats}
</div>
<div data-slot="session-review-v2-sidebar-filter">
<TextInputV2
type="search"
value={props.filter}
onInput={(event) => props.onFilterChange(event.currentTarget.value)}
onKeyDown={props.onFilterKeyDown}
autofocus={props.filterAutofocus}
ref={props.filterRef}
role={props.filterControls ? "combobox" : undefined}
aria-autocomplete={props.filterControls ? "list" : undefined}
aria-controls={props.filterControls}
aria-activedescendant={props.filterActiveDescendant}
aria-expanded={props.filterControls ? props.filterExpanded : undefined}
showClearButton={props.filter.length > 0}
clearLabel={i18n.t("ui.list.clearFilter")}
onClearClick={() => props.onFilterChange("")}
placeholder={i18n.t("ui.sessionReviewV2.filterFiles")}
aria-label={i18n.t("ui.sessionReviewV2.filterFiles")}
leadingIcon={
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.25 12.25L10.0625 10.0625M11.0833 6.41667C11.0833 8.994 8.994 11.0833 6.41667 11.0833C3.83934 11.0833 1.75 8.994 1.75 6.41667C1.75 3.83934 3.83934 1.75 6.41667 1.75C8.994 1.75 11.0833 3.83934 11.0833 6.41667Z"
stroke="currentColor"
stroke-linecap="square"
/>
</svg>
}
/>
</div>
<ScrollView
data-slot="session-review-v2-sidebar-tree"
class="group/file-tree-v2"
thumbVisibility="scroll"
viewportRef={props.viewportRef}
>
{props.children}
</ScrollView>
</aside>
</Show>
<Show when={props.open && props.onWidthChange}>
<div data-slot="session-review-v2-sidebar-resize" onPointerDown={() => setResizing(true)}>
<ResizeHandle