mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
+12
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user