fix(splits): dim terminals and chats on the unfocused side like editors (#28952)

* Transfer keyboard focus when a sibling chat pane opens

* fix(native-chat): hand focus to newly opened chat composer

* fix(native-chat): keep tab bar controller within line limit

* fix(chat): use terminal pane opacity for split chat focus

* test(chat): verify split opacity follows the focused group

* fix(chat): an unfocused split chat dims exactly as much as a split terminal

A split chat was dimmed with the terminal's inner-pane opacity (0.9 by
default) while terminals and editors in an unfocused split group get the
group's 0.95, so chats looked dimmer than terminals beside them. The chat
layer now takes the same shared group dim and nothing more.

* fix(chat): terminals and chats in an unfocused split dim like editors

Editors and tab bars in an unfocused split group were dimmed to 0.95, but
terminals and chats are drawn in a layer outside the group, so they stayed
at full opacity. One shared opacity now dims the group panel and the
retained terminal and chat hosts alike, so all three look the same.
This commit is contained in:
Brennan Benson
2026-10-21 06:25:32 -07:00
committed by GitHub
parent ad269f471c
commit fd8b2dc3a7
7 changed files with 34 additions and 4 deletions
@@ -2,6 +2,7 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { UNFOCUSED_SPLIT_GROUP_OPACITY } from '../tab-group/split-group-dim'
import type { Tab, TabGroup } from '../../../../shared/tab-types'
type MockAppState = {
@@ -216,6 +217,17 @@ describe('StructuredAgentSessionPaneOverlayLayer', () => {
expect(chatSurface(view.container, SECOND_TAB_ID).dataset.chatVisible).toBe('true')
expect(chatSurface(view.container, FIRST_TAB_ID).dataset.chatFocusedGroup).toBe('false')
expect(chatSurface(view.container, SECOND_TAB_ID).dataset.chatFocusedGroup).toBe('true')
const host = (tabId: string): HTMLElement | null =>
chatSurface(view.container, tabId).closest<HTMLElement>('[data-retained-pane-host]')
const first = host(FIRST_TAB_ID)
const second = host(SECOND_TAB_ID)
const dim = String(UNFOCUSED_SPLIT_GROUP_OPACITY)
// The unfocused chat takes the same dim as every other unfocused split group, never more.
expect(first?.style.opacity).toBe(dim)
expect(second?.style.opacity).toBe('1')
act(() => mocks.store?.setState({ activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID } }))
expect(first?.style.opacity).toBe('1')
expect(second?.style.opacity).toBe(dim)
})
it('marks no column as focused when the focused group id names nothing', () => {
@@ -23,12 +23,14 @@ const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOv
groupId,
isActive,
isFocusedGroup,
hasSplitGroups,
onFocusOwningGroup
}: {
tab: StructuredAgentSessionTab
groupId: string | undefined
isActive: boolean
isFocusedGroup: boolean
hasSplitGroups: boolean
onFocusOwningGroup: ((groupId: string) => void) | undefined
}): React.JSX.Element | null {
// Each chat is read from the host recorded on its tab, never from its workspace id, which two
@@ -42,6 +44,7 @@ const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOv
<RetainedPaneHost
groupId={groupId}
isVisible={isActive}
dimmed={hasSplitGroups && !isFocusedGroup}
data-structured-agent-session-overlay-tab-id={tab.id}
onFocusOwningGroup={onFocusOwningGroup}
>
@@ -101,6 +104,7 @@ const StructuredAgentSessionPaneOverlayLayer = memo(
groupActiveTabById.get(tab.groupId) === tab.id &&
tab.groupId === activeGroupId
)}
hasSplitGroups={groups.length > 1}
onFocusOwningGroup={focusOwningGroup}
/>
))}
@@ -2,6 +2,7 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
import { tabGroupBodyAnchorName } from './tab-group-body-anchor'
import { subscribeTabGroupBodyRect, type TabGroupBodyRect } from './tab-group-body-geometry'
import { UNFOCUSED_SPLIT_GROUP_OPACITY } from './split-group-dim'
const HAS_CSS_ANCHOR_POSITIONING =
typeof CSS !== 'undefined' &&
@@ -23,6 +24,8 @@ type RetainedPaneHostProps = {
isVisible: boolean
measureWhileHidden?: boolean
fitTerminal?: boolean
/** Unfocused side of a split: overlays sit outside the group's DOM, so they take its dim here. */
dimmed?: boolean
onFocusOwningGroup?: (groupId: string) => void
children: React.ReactNode
'data-terminal-overlay-tab-id'?: string
@@ -34,6 +37,7 @@ export function RetainedPaneHost({
isVisible,
measureWhileHidden = false,
fitTerminal = false,
dimmed = false,
onFocusOwningGroup,
children,
...identity
@@ -94,6 +98,7 @@ export function RetainedPaneHost({
}
}, [anchorName, fitTerminal, isVisible, measuredFallbackRect])
const visibleOpacity = dimmed ? UNFOCUSED_SPLIT_GROUP_OPACITY : 1
const style: React.CSSProperties = useMemo(
() =>
anchorName && shouldUseCssAnchorPositioning()
@@ -105,7 +110,7 @@ export function RetainedPaneHost({
width: `anchor-size(${anchorName} width)`,
height: `anchor-size(${anchorName} height)`,
display: isVisible || measureWhileHidden ? 'flex' : 'none',
opacity: isVisible ? 1 : 0,
opacity: isVisible ? visibleOpacity : 0,
pointerEvents: isVisible ? 'auto' : 'none'
}
: anchorName
@@ -119,7 +124,7 @@ export function RetainedPaneHost({
width: measuredFallbackRect?.width ?? '100%',
height: measuredFallbackRect?.height ?? 'calc(100% - 32px)',
display: isVisible || measureWhileHidden ? 'flex' : 'none',
opacity: isVisible ? 1 : 0,
opacity: isVisible ? visibleOpacity : 0,
pointerEvents: isVisible ? 'auto' : 'none'
}
: {
@@ -131,7 +136,7 @@ export function RetainedPaneHost({
display: 'none',
pointerEvents: 'none'
},
[anchorName, isVisible, measuredFallbackRect, measureWhileHidden]
[anchorName, isVisible, measuredFallbackRect, measureWhileHidden, visibleOpacity]
)
const focusGroup = useCallback(() => {
if (groupId !== undefined && onFocusOwningGroup) {
@@ -20,6 +20,7 @@ import { useClientHostedBrowserRows } from '@/lib/pane-manager/client-hosted-bro
import { resolveClientHostedBrowserRowStripGroupId } from '../tab-bar/client-hosted-browser-row-strip-placement'
import { useEditorGroupFileDropOwner } from '../editor/use-editor-group-file-drop-owner'
import { cn } from '@/lib/utils'
import { UNFOCUSED_SPLIT_GROUP_OPACITY } from './split-group-dim'
const EditorPanel = lazy(() => import('../editor/EditorPanel'))
const EMPTY_GROUPS: readonly TabGroup[] = []
@@ -259,9 +260,10 @@ export default function TabGroupPanel({
touchesBottomEdge || suppressBottomBorder ? '' : 'border-b'
} border-border ${
isFocused && !touchesBottomEdge && !suppressBottomBorder ? 'border-b-accent' : ''
} ${isFocused ? '' : 'opacity-95'}`
}`
: ''
}`}
style={hasSplitGroups && !isFocused ? { opacity: UNFOCUSED_SPLIT_GROUP_OPACITY } : undefined}
onPointerDown={commands.focusGroup}
// Why: keyboard/AT focus can enter a split group without a pointer event, so sync group focus to DOM focus for global shortcuts.
onFocusCapture={commands.focusGroup}
@@ -0,0 +1,2 @@
/** The one dim every unfocused split group's content gets, terminals, editors and chats alike. */
export const UNFOCUSED_SPLIT_GROUP_OPACITY = 0.95
@@ -18,6 +18,8 @@ type TerminalOverlaySlotProps = {
isWorktreeActive: boolean
isVisible: boolean
isActive: boolean
/** Visible on the unfocused side of a split. */
dimmed?: boolean
activityTerminalPortal: ActivityTerminalPortalTarget | null
onFocusOwningGroup: ((groupId: string) => void) | undefined
consumeSuppressedPtyExit: (ptyId: string) => boolean
@@ -35,6 +37,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
isWorktreeActive,
isVisible,
isActive,
dimmed = false,
activityTerminalPortal,
onFocusOwningGroup,
consumeSuppressedPtyExit,
@@ -106,6 +109,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
isVisible={isVisible}
measureWhileHidden={shouldMeasureHiddenStartup}
fitTerminal
dimmed={dimmed}
data-terminal-overlay-tab-id={terminalTabId}
onFocusOwningGroup={onFocusOwningGroup}
>
@@ -168,6 +168,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
isWorktreeActive={isWorktreeActive}
isVisible={isVisible}
isActive={isActive}
dimmed={groups.length > 1 && isVisible && !isActive}
activityTerminalPortal={activityTerminalPortal}
onFocusOwningGroup={focusOwningGroup}
consumeSuppressedPtyExit={consumeSuppressedPtyExit}