From fd8b2dc3a7c557be940e635327f461229cef1644 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:25:32 -0700 Subject: [PATCH] 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. --- .../StructuredAgentSessionPaneOverlayLayer.test.tsx | 12 ++++++++++++ .../StructuredAgentSessionPaneOverlayLayer.tsx | 4 ++++ .../src/components/tab-group/RetainedPaneHost.tsx | 11 ++++++++--- .../src/components/tab-group/TabGroupPanel.tsx | 4 +++- .../src/components/tab-group/split-group-dim.ts | 2 ++ .../components/terminal-pane/TerminalOverlaySlot.tsx | 4 ++++ .../terminal-pane/TerminalPaneOverlayLayer.tsx | 1 + 7 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/tab-group/split-group-dim.ts diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx index a95dfee553a..053ef56e19b 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx @@ -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('[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', () => { diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx index 1cb17596965..5cf531eba85 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx @@ -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 @@ -101,6 +104,7 @@ const StructuredAgentSessionPaneOverlayLayer = memo( groupActiveTabById.get(tab.groupId) === tab.id && tab.groupId === activeGroupId )} + hasSplitGroups={groups.length > 1} onFocusOwningGroup={focusOwningGroup} /> ))} diff --git a/src/renderer/src/components/tab-group/RetainedPaneHost.tsx b/src/renderer/src/components/tab-group/RetainedPaneHost.tsx index 869c10bdce8..c4d2b4d0189 100644 --- a/src/renderer/src/components/tab-group/RetainedPaneHost.tsx +++ b/src/renderer/src/components/tab-group/RetainedPaneHost.tsx @@ -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) { diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 45c67f946a1..6e623c14482 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -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} diff --git a/src/renderer/src/components/tab-group/split-group-dim.ts b/src/renderer/src/components/tab-group/split-group-dim.ts new file mode 100644 index 00000000000..ccc1669f584 --- /dev/null +++ b/src/renderer/src/components/tab-group/split-group-dim.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx index 9f09fe9b6a6..f925240e123 100644 --- a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx @@ -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} > diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 9a877d13791..5917f9e7952 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -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}