diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index ce53af7fed9e..653955b4d8c4 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -50,7 +50,9 @@ function isCodexNoRolloutError(error: unknown, threadId: string): boolean { * every Orca key the app-server holds. Codex adds `set` keys after that filter and merges these * per key, so the user's own policy stays as written. */ -function shellEnvironmentSetConfig(env: Record | undefined): Record { +function shellEnvironmentSetConfig( + env: Record | undefined +): Record { return Object.fromEntries( Object.entries(env ?? {}).map(([key, value]) => [`shell_environment_policy.set.${key}`, value]) ) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx index a95dfee553ac..053ef56e19bd 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 1cb17596965b..5cf531eba85b 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 869c10bdce85..c4d2b4d0189f 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 45c67f946a1a..6e623c144821 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 000000000000..ccc1669f584d --- /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 9f09fe9b6a6a..f925240e1232 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 9a877d13791b..5917f9e79529 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}