From f086c3ac3da7bc642c952968cb818afb039dda5f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:09:16 -0700 Subject: [PATCH 1/7] Transfer keyboard focus when a sibling chat pane opens --- .../native-chat/native-chat-composer-reveal-focus.test.tsx | 5 ++++- .../native-chat/use-native-chat-composer-reveal-focus.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx index daab2c0bf50f..840b0283f8d4 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx @@ -209,16 +209,19 @@ describe('useNativeChatComposerRevealFocus', () => { expect(focusCalls).toBe(1) }) - it('lets only the focused group claim when two panes are revealed together', () => { + it('hands focus from a sibling composer to the newly focused pane', () => { const other = document.createElement('div') + other.setAttribute('data-retained-pane-host', '') document.body.appendChild(other) const otherRoot = createRoot(other) act(() => { root.render(createElement(Harness, { isVisible: true, isFocusedGroup: true })) otherRoot.render(createElement(Harness, { isVisible: true, isFocusedGroup: false })) }) + other.querySelector('textarea')?.focus() drainFrames() expect(focusCalls).toBe(1) + expect(container.querySelector('textarea')).toBe(document.activeElement) act(() => { otherRoot.unmount() }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts index 054d32d743e7..8241d1eccafa 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts @@ -118,7 +118,10 @@ export function useNativeChatComposerRevealFocus({ } // Why: a live text field is a user edit — a batch worktree-create keeps its next name // field open behind the pane we just revealed, and the find bar keeps its query. - if (shouldPreserveEditableFocus(active)) { + // A sibling pane's composer must yield when this pane becomes focused. + const editingSiblingPane = + active?.closest('[data-retained-pane-host]')?.contains(rootRef.current) === false + if (shouldPreserveEditableFocus(active) && !editingSiblingPane) { finish() return } From 0ed5bb4bd4b53d62e244d07c9d71ffcb83068ef3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:34:58 -0700 Subject: [PATCH 2/7] fix(native-chat): hand focus to newly opened chat composer --- ...native-chat-composer-reveal-focus.test.tsx | 79 +++++++++++++-- .../use-native-chat-composer-reveal-focus.ts | 79 ++++++++++++++- .../components/tab-bar/QuickLaunchButton.tsx | 2 + .../use-tab-bar-create-menu-controller.ts | 2 + .../components/use-terminal-create-actions.ts | 2 + ...aunch-agent-in-new-tab-host-agents.test.ts | 4 +- .../launch-agent-in-new-tab-host-agents.ts | 6 +- ...aunch-agent-in-new-tab-structured-route.ts | 3 + .../src/lib/launch-agent-in-new-tab.test.ts | 9 +- .../src/lib/launch-agent-in-new-tab.ts | 13 ++- ...ured-agent-session-provisional-tab.test.ts | 8 +- ...tructured-agent-session-provisional-tab.ts | 99 ++++++++++++------- 12 files changed, 254 insertions(+), 52 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx index 840b0283f8d4..d3f1092c1aa3 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx @@ -3,12 +3,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { act, createElement, useRef } from 'react' import { createRoot, type Root } from 'react-dom/client' import { + captureNativeChatComposerFocusIntent, requestNativeChatComposerFocus, useNativeChatComposerRevealFocus } from './use-native-chat-composer-reveal-focus' import type { NativeChatComposerHandle } from './NativeChatComposer' type HarnessProps = { + tabId?: string isVisible: boolean isFocusedGroup: boolean composerReady?: boolean @@ -58,10 +60,10 @@ function Harness(props: HarnessProps): React.JSX.Element { acceptsText: () => true, handlePasteEvent: () => {}, pasteFromClipboard: () => {}, - contains: () => false + contains: (node) => fieldRef.current?.contains(node) === true } useNativeChatComposerRevealFocus({ - tabId: 'chat-tab', + tabId: props.tabId ?? 'chat-tab', rootRef, composerRef, isVisible: props.isVisible, @@ -76,7 +78,8 @@ function Harness(props: HarnessProps): React.JSX.Element { { ref: rootRef }, createElement('textarea', { ref: fieldRef, 'data-testid': 'composer' }), createElement('button', { type: 'button', 'data-testid': 'in-pane-button' }), - createElement('input', { 'data-testid': 'find-input' }) + createElement('input', { 'data-testid': 'find-input' }), + createElement('input', { 'data-testid': 'question-input' }) ) } @@ -95,6 +98,7 @@ beforeEach(() => { }) afterEach(() => { + document.dispatchEvent(new Event('pointerdown')) act(() => { root.unmount() }) @@ -165,15 +169,45 @@ describe('useNativeChatComposerRevealFocus', () => { expect(document.activeElement).toBe(button) }) - it('ignores a tab activation while the chat is not revealed or for another tab', () => { - render({ isVisible: true, isFocusedGroup: false }) + it('delivers a created tab request after its composer mounts and becomes ready', () => { + render({ tabId: 'source-tab', isVisible: true, isFocusedGroup: true }) drainFrames() - act(() => requestNativeChatComposerFocus('chat-tab')) + const sourceComposer = container.querySelector('textarea') + expect(document.activeElement).toBe(sourceComposer) + + const intent = captureNativeChatComposerFocusIntent() + intent.request('chat-tab') drainFrames() - expect(focusCalls).toBe(0) + expect(document.activeElement).toBe(sourceComposer) - render({ isVisible: true, isFocusedGroup: true }) + const destination = document.createElement('div') + document.body.appendChild(destination) + const destinationRoot = createRoot(destination) + act(() => { + root.render( + createElement(Harness, { tabId: 'source-tab', isVisible: true, isFocusedGroup: false }) + ) + destinationRoot.render( + createElement(Harness, { isVisible: true, isFocusedGroup: false, composerReady: false }) + ) + }) drainFrames() + expect(document.activeElement).toBe(sourceComposer) + + act(() => { + destinationRoot.render( + createElement(Harness, { isVisible: true, isFocusedGroup: true, composerReady: false }) + ) + }) + drainFrames() + expect(document.activeElement).toBe(sourceComposer) + act(() => { + destinationRoot.render( + createElement(Harness, { isVisible: true, isFocusedGroup: true, composerReady: true }) + ) + }) + drainFrames() + expect(document.activeElement).toBe(destination.querySelector('textarea')) const before = focusCalls if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() @@ -181,6 +215,8 @@ describe('useNativeChatComposerRevealFocus', () => { act(() => requestNativeChatComposerFocus('other-tab')) drainFrames() expect(focusCalls).toBe(before) + act(() => destinationRoot.unmount()) + destination.remove() }) it('focuses when it mounts already revealed', () => { @@ -216,12 +252,37 @@ describe('useNativeChatComposerRevealFocus', () => { const otherRoot = createRoot(other) act(() => { root.render(createElement(Harness, { isVisible: true, isFocusedGroup: true })) - otherRoot.render(createElement(Harness, { isVisible: true, isFocusedGroup: false })) + otherRoot.render( + createElement(Harness, { tabId: 'other-tab', isVisible: true, isFocusedGroup: false }) + ) }) other.querySelector('textarea')?.focus() drainFrames() + expect(focusCalls).toBe(0) + expect(other.querySelector('textarea')).toBe(document.activeElement) + + act(() => requestNativeChatComposerFocus('chat-tab')) + drainFrames() expect(focusCalls).toBe(1) expect(container.querySelector('textarea')).toBe(document.activeElement) + + for (const field of ['find-input', 'question-input']) { + const sourceInput = other.querySelector(`[data-testid="${field}"]`) + sourceInput?.focus() + act(() => requestNativeChatComposerFocus('chat-tab')) + drainFrames() + expect(document.activeElement).toBe(sourceInput) + } + expect(focusCalls).toBe(1) + + const sourceComposer = other.querySelector('textarea') + sourceComposer?.focus() + const delayedIntent = captureNativeChatComposerFocusIntent() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })) + delayedIntent.request('chat-tab') + drainFrames() + expect(document.activeElement).toBe(sourceComposer) + act(() => { otherRoot.unmount() }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts index 8241d1eccafa..06e3972a8e97 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts @@ -6,12 +6,63 @@ import type { NativeChatComposerHandle } from './NativeChatComposer' /** Focus attempts per reveal, including recovery from delayed programmatic restoration. */ const REVEAL_FOCUS_FRAMES = 6 +const PENDING_FOCUS_MS = 1000 const revealedComposerReclaims = new Map void>() +const mountedComposers = new Set>() +let pendingFocusRequest: { tabId: string | null; cancel: () => void } | null = null +let focusRequestGeneration = 0 + +export type NativeChatComposerFocusIntent = { + request: (tabId: string) => void + cancel: () => void +} + +/** Keep a new-chat action's focus intent through destination claim, navigation, or expiry. */ +export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusIntent { + focusRequestGeneration += 1 + pendingFocusRequest?.cancel() + if (typeof document === 'undefined') { + return { request: () => undefined, cancel: () => undefined } + } + const cancel = (): void => { + if (pendingFocusRequest !== pending) { + return + } + pendingFocusRequest = null + clearTimeout(timeout) + document.removeEventListener('pointerdown', cancel, true) + document.removeEventListener('keydown', cancelForNavigation, true) + } + const cancelForNavigation = (event: KeyboardEvent): void => { + if (event.key === 'Tab') { + cancel() + } + } + const pending: { tabId: string | null; cancel: () => void } = { tabId: null, cancel } + const timeout = setTimeout(cancel, PENDING_FOCUS_MS) + pendingFocusRequest = pending + document.addEventListener('pointerdown', cancel, true) + document.addEventListener('keydown', cancelForNavigation, true) + return { + request: (tabId) => { + if (pendingFocusRequest !== pending) { + return + } + pending.tabId = tabId + const reclaim = revealedComposerReclaims.get(tabId) + if (reclaim) { + cancel() + reclaim() + } + }, + cancel + } +} /** Re-clicking a chat's active tab claims its composer again, as a terminal tab refocuses xterm. */ export function requestNativeChatComposerFocus(tabId: string): void { - revealedComposerReclaims.get(tabId)?.() + captureNativeChatComposerFocusIntent().request(tabId) } export type NativeChatComposerRevealFocusArgs = { @@ -48,6 +99,13 @@ export function useNativeChatComposerRevealFocus({ const explicitRef = useRef(false) const [reclaimCount, setReclaimCount] = useState(0) + useEffect(() => { + mountedComposers.add(composerRef) + return () => { + mountedComposers.delete(composerRef) + } + }, [composerRef]) + useEffect(() => { if (!revealed || !composerReady) { return @@ -58,6 +116,11 @@ export function useNativeChatComposerRevealFocus({ setReclaimCount((count) => count + 1) } revealedComposerReclaims.set(tabId, reclaim) + if (pendingFocusRequest?.tabId === tabId) { + pendingFocusRequest.cancel() + claimedRef.current = false + explicitRef.current = true + } return () => { if (revealedComposerReclaims.get(tabId) === reclaim) { revealedComposerReclaims.delete(tabId) @@ -77,6 +140,7 @@ export function useNativeChatComposerRevealFocus({ let cancelled = false let userInteracted = false let attempts = 0 + const requestGeneration = focusRequestGeneration const ownerDocument = rootRef.current?.ownerDocument const cancelForPointer = (): void => { userInteracted = true @@ -101,6 +165,10 @@ export function useNativeChatComposerRevealFocus({ if (cancelled) { return } + if (requestGeneration !== focusRequestGeneration) { + stopWatchingUserIntent() + return + } if (userInteracted) { finish() return @@ -118,10 +186,11 @@ export function useNativeChatComposerRevealFocus({ } // Why: a live text field is a user edit — a batch worktree-create keeps its next name // field open behind the pane we just revealed, and the find bar keeps its query. - // A sibling pane's composer must yield when this pane becomes focused. - const editingSiblingPane = - active?.closest('[data-retained-pane-host]')?.contains(rootRef.current) === false - if (shouldPreserveEditableFocus(active) && !editingSiblingPane) { + const editingSiblingComposer = + explicitRef.current && + rootRef.current?.contains(active) !== true && + [...mountedComposers].some((ownerRef) => ownerRef.current?.contains(active)) + if (shouldPreserveEditableFocus(active) && !editingSiblingComposer) { finish() return } diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 8622e567160e..c00dcc07f01b 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -9,6 +9,7 @@ import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' +import { captureNativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import type { TuiAgent } from '../../../../shared/tui-agent' import type { LaunchSource } from '../../../../shared/telemetry-events' import { @@ -141,6 +142,7 @@ function QuickLaunchAgentMenuItemsInner({ const label = entry?.label ?? agent const result = launchAgentInNewTab({ requestId: newAgentLaunchRequestId(), + focusIntent: captureNativeChatComposerFocusIntent(), agent, worktreeId, groupId, diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index dcbc45df788e..204c0665d1e8 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -8,6 +8,7 @@ import { shouldQueueTerminalFocusAfterMenuClose } from '@/lib/launch-agent-in-new-tab' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' +import { captureNativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import type { WindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { useAppStore } from '../../store' import type { TabAgentLaunchOption } from './tab-agent-launch-options' @@ -225,6 +226,7 @@ export function useTabBarCreateMenuController({ const option = agentLaunchOptions.find((candidate) => candidate.agent === agent) const result = launchAgentInNewTab({ requestId: newAgentLaunchRequestId(), + focusIntent: captureNativeChatComposerFocusIntent(), agent, worktreeId, groupId: resolvedGroupId, diff --git a/src/renderer/src/components/use-terminal-create-actions.ts b/src/renderer/src/components/use-terminal-create-actions.ts index 2801af6e2a9b..4fcdb07ed40b 100644 --- a/src/renderer/src/components/use-terminal-create-actions.ts +++ b/src/renderer/src/components/use-terminal-create-actions.ts @@ -11,6 +11,7 @@ import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' +import { captureNativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' @@ -73,6 +74,7 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr state.groupsByWorktree[activeWorktreeId]?.[0]?.id const result = launchAgentInNewTab({ requestId: newAgentLaunchRequestId(), + focusIntent: captureNativeChatComposerFocusIntent(), agent, worktreeId: activeWorktreeId, groupId: targetGroupId, diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts index 6616205f6897..8fff2f533d9d 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts @@ -103,8 +103,9 @@ describe('a new tab whose chat route waits on the host agent list', () => { throw new Error('expected the launch to wait for the host agent list') } const relaunch = fakeRelaunch() + const focusIntent = { request: vi.fn(), cancel: vi.fn() } - const result = launchOnceHostAnswered(route, args, startupPlan, relaunch) + const result = launchOnceHostAnswered(route, { ...args, focusIntent }, startupPlan, relaunch) expect(result?.surface).toEqual({ kind: 'host-published' }) expect(relaunch).not.toHaveBeenCalled() @@ -114,6 +115,7 @@ describe('a new tab whose chat route waits on the host agent list', () => { }) expect(relaunch.mock.calls[0]?.[0]).toMatchObject({ requestId: undefined, + focusIntent, agentSessionLaunchPlan: { route: 'structured-native-chat', requestId: 'request-1' } }) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts index b78f34856d44..9b24abef455e 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts @@ -55,6 +55,7 @@ export function launchOnceHostAnswered( relaunch: (args: LaunchAgentInNewTabArgs) => LaunchAgentInNewTabResult ): LaunchAgentInNewTabResult { if (args.beforeSurfaceOpen?.({ kind: 'host-published' }) === false) { + args.focusIntent?.cancel() return null } const opened = route.awaited.then(() => @@ -70,7 +71,10 @@ export function launchOnceHostAnswered( !result ? { kind: 'cancelled', sessionId: null } : (result.structuredSettlement ?? { kind: 'terminal' }), - (error: unknown): StructuredAgentLaunchSettlement => ({ kind: 'failed', error }) + (error: unknown): StructuredAgentLaunchSettlement => { + args.focusIntent?.cancel() + return { kind: 'failed', error } + } ) const deliversPrompt = Boolean(args.prompt?.trim()) && (args.promptDelivery ?? 'auto-submit') !== 'draft' diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts index 46d12f15db18..30574b1fca1c 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts @@ -7,6 +7,7 @@ import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-lau import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' +import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' type StructuredFromNewTab = { surface: AgentLaunchSurface @@ -24,6 +25,7 @@ export function launchStructuredAgentFromNewTab(args: { plan: AgentSessionLaunchPlan worktreeId: string groupId?: string + focusIntent?: NativeChatComposerFocusIntent beforeSurfaceOpen?: LaunchAgentInNewTabArgs['beforeSurfaceOpen'] onHostDeclined?: () => StructuredLaunchTerminal openTerminal: (terminalPlan: AgentSessionLaunchPlan) => { @@ -37,6 +39,7 @@ export function launchStructuredAgentFromNewTab(args: { const launch = beginStructuredAgentSessionProvisionalLaunch({ plan, hooks: {}, + ...(args.focusIntent ? { focusIntent: args.focusIntent } : {}), target: { worktreeId: args.worktreeId }, ...(args.groupId ? { targetGroupId: args.groupId } : {}), onHostDeclined: () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 51c011a54d3b..885be5b6ae87 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -213,9 +213,16 @@ describe('launchAgentInNewTab', () => { it('stamps the launched agent on the new tab for immediate provider icon bootstrap', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + const cancelFocus = vi.fn() - launchAgentInNewTab({ requestId: 'request-1', agent: 'codex', worktreeId: 'wt-1' }) + launchAgentInNewTab({ + requestId: 'request-1', + agent: 'codex', + worktreeId: 'wt-1', + focusIntent: { request: vi.fn(), cancel: cancelFocus } + }) + expect(cancelFocus).toHaveBeenCalledOnce() expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { launchAgent: 'codex', pendingStartup: expect.anything() diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index c8988afffc0d..4697207b1853 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -30,6 +30,7 @@ import { routeNewTabLaunch } from '@/lib/launch-agent-in-new-tab-host-agents' import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id' +import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' /** The user action this launch serves: minted where that action is handled, or carried by the * route the caller already planned for it. */ @@ -44,6 +45,8 @@ type LaunchAgentInNewTabRequest = export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & { agent: TuiAgent worktreeId: string + /** Captured at a new-chat action before the host can delay route selection. */ + focusIntent?: NativeChatComposerFocusIntent /** Tab group the user launched from; keeps split-group launches in that pane instead of the active group. */ groupId?: string /** Optional initial prompt; delivery depends on `promptDelivery` and the agent's prompt mode. */ @@ -132,6 +135,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent const store = useAppStore.getState() const executionContext = resolveAgentLaunchExecutionContext(store, { worktreeId, launchPlatform }) if (!executionContext) { + args.focusIntent?.cancel() return null } const { resolvedLaunchPlatform, isRemote, queuedShell } = executionContext @@ -166,6 +170,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined if (!startupPlan) { + args.focusIntent?.cancel() return null } @@ -188,6 +193,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent const structured = launchStructuredAgentFromNewTab({ plan, worktreeId, + ...(args.focusIntent ? { focusIntent: args.focusIntent } : {}), ...(groupId ? { groupId } : {}), ...(beforeSurfaceOpen ? { beforeSurfaceOpen } : {}), ...(args.onStructuredHostDeclined ? { onHostDeclined: args.onStructuredHostDeclined } : {}), @@ -200,9 +206,14 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent agentSessionLaunchPlan: terminalPlan }) }) - return structured && { ...structured, startupPlan } + if (!structured) { + args.focusIntent?.cancel() + return null + } + return { ...structured, startupPlan } } + args.focusIntent?.cancel() const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { if (beforeSurfaceOpen?.({ kind: 'host-published' }) === false) { diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index 87bbac89df74..9acc430be544 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -5,7 +5,9 @@ import type { StructuredAgentLaunchSettlement } from './structured-agent-launch- const mocks = vi.hoisted(() => ({ createUnifiedTab: vi.fn(), - createSupport: vi.fn() + createSupport: vi.fn(), + requestFocus: vi.fn(), + cancelFocus: vi.fn() })) vi.mock('@/store', () => ({ @@ -55,11 +57,13 @@ describe('a provisional structured chat tab', () => { const launch = beginStructuredAgentSessionProvisionalLaunch({ plan: planSettlingAs({ kind: 'structured', sessionId: 'claude_1' }), hooks: {}, + focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, targetGroupId: 'group-2' }) expect(launch?.tab).toBeNull() expect(mocks.createUnifiedTab).not.toHaveBeenCalled() + expect(mocks.requestFocus).not.toHaveBeenCalled() await expect(launch?.settlement).resolves.toEqual({ kind: 'structured', sessionId: 'claude_1' }) expect(mocks.createSupport).toHaveBeenCalledOnce() expect(mocks.createUnifiedTab).toHaveBeenCalledWith( @@ -67,5 +71,7 @@ describe('a provisional structured chat tab', () => { 'agent-session', expect.objectContaining({ executionHostId: 'local', targetGroupId: 'group-2' }) ) + expect(mocks.requestFocus).toHaveBeenCalledExactlyOnceWith('structured-agent-session-claude_1') + expect(mocks.cancelFocus).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index ab416b89298f..8235e0a2ba2e 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -24,6 +24,7 @@ import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } @@ -92,6 +93,7 @@ export function structuredLaunchOwner( type ProvisionalLaunchArgs = { plan: AgentSessionLaunchPlan hooks: StructuredAgentLaunchHooks + focusIntent?: NativeChatComposerFocusIntent target?: AgentSessionLaunchTarget targetGroupId?: string activate?: boolean @@ -110,42 +112,73 @@ type ProvisionalLaunchArgs = { export function beginStructuredAgentSessionProvisionalLaunch( args: ProvisionalLaunchArgs ): StructuredAgentSessionProvisionalLaunch | null { + const focusIntent = args.focusIntent + if (args.activate === false) { + focusIntent?.cancel() + } const worktreeId = args.target?.worktreeId ?? args.plan.worktreeId - const owner = worktreeId ? structuredLaunchOwner(args.plan, worktreeId, args.target) : null - if (!owner || !worktreeId) { - // No host to ask: the launch path refuses it with its own message. - return beginLocalProvisionalLaunch(args) + let tabOpened = false + const cancelIfNoTab = (): void => { + if (!tabOpened) { + focusIntent?.cancel() + } } - if (args.beforeOpen?.() === false) { - return null + const open = (launch: LocalProvisionalLaunch | null): LocalProvisionalLaunch | null => { + if (launch) { + tabOpened = true + if (args.activate !== false) { + focusIntent?.request(launch.tab.id) + } + } else { + cancelIfNoTab() + } + return launch + } + try { + const owner = worktreeId ? structuredLaunchOwner(args.plan, worktreeId, args.target) : null + if (owner && worktreeId && args.beforeOpen?.() === false) { + cancelIfNoTab() + return null + } + if (!owner || !worktreeId) { + // No host to ask: the launch path refuses it with its own message. + return open(beginLocalProvisionalLaunch(args)) + } + const launch = beginHostAdmittedStructuredLaunch({ + plan: args.plan, + hooks: args.hooks, + worktreeId, + executionHostId: owner.executionHostId, + target: owner.target, + openAdmitted: (seedOptions) => + open( + beginLocalProvisionalLaunch({ + ...args, + target: { + ...args.target, + worktreeId, + executionHostId: owner.executionHostId, + ...(seedOptions ? { seedOptions } : {}) + }, + beforeOpen: undefined + }) + ), + onHostDeclined: + args.onHostDeclined ?? + (() => + openDeclinedStructuredLaunchTerminal({ + plan: args.plan, + worktreeId, + ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), + ...(args.declinedTerminal ? { terminal: args.declinedTerminal } : {}) + })) + }) + void launch.settlement.then(cancelIfNoTab, cancelIfNoTab) + return launch + } catch (error) { + cancelIfNoTab() + throw error } - return beginHostAdmittedStructuredLaunch({ - plan: args.plan, - hooks: args.hooks, - worktreeId, - executionHostId: owner.executionHostId, - target: owner.target, - openAdmitted: (seedOptions) => - beginLocalProvisionalLaunch({ - ...args, - target: { - ...args.target, - worktreeId, - executionHostId: owner.executionHostId, - ...(seedOptions ? { seedOptions } : {}) - }, - beforeOpen: undefined - }), - onHostDeclined: - args.onHostDeclined ?? - (() => - openDeclinedStructuredLaunchTerminal({ - plan: args.plan, - worktreeId, - ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), - ...(args.declinedTerminal ? { terminal: args.declinedTerminal } : {}) - })) - }) } function beginLocalProvisionalLaunch(args: ProvisionalLaunchArgs): LocalProvisionalLaunch | null { From 6167d52041119c97a546a4222ec8159353940a2c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:44:40 -0700 Subject: [PATCH 3/7] fix(native-chat): keep tab bar controller within line limit --- .../src/components/tab-bar/use-tab-bar-create-menu-controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index 204c0665d1e8..4ea998ac0b30 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -219,7 +219,6 @@ export function useTabBarCreateMenuController({ case 'new-simulator': case 'go-to-simulator': onNewSimulatorTab?.() - break } } const launchAgentFromNewTabEntry = (agent: TuiAgent): void => { From 67f7f7cf7f1dafab2c6d4606ecf6f00025fa4007 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:40:14 -0700 Subject: [PATCH 4/7] fix(chat): use terminal pane opacity for split chat focus --- ...StructuredAgentSessionPaneOverlayLayer.tsx | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx index 1cb17596965b..7c4c4a95621a 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx @@ -9,6 +9,7 @@ import { import { RetainedPaneHost } from '../tab-group/RetainedPaneHost' import NativeChatView from './NativeChatView' import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' +import { resolvePaneStyleOptions } from '@/lib/terminal-theme' type StructuredAgentSessionTab = Tab & { contentType: 'agent-session' @@ -23,18 +24,22 @@ 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 // hosts can share. const owner = useAppStore((state) => structuredAgentSessionOwnerForTab(state, tab)) const target = useMemo(() => structuredAgentSessionTargetForHost(owner), [owner]) + const settings = useAppStore((state) => state.settings) + const paneStyle = settings ? resolvePaneStyleOptions(settings) : undefined if (!target) { return null } @@ -45,16 +50,30 @@ const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOv data-structured-agent-session-overlay-tab-id={tab.id} onFocusOwningGroup={onFocusOwningGroup} > - +
+ +
) }) @@ -101,6 +120,7 @@ const StructuredAgentSessionPaneOverlayLayer = memo( groupActiveTabById.get(tab.groupId) === tab.id && tab.groupId === activeGroupId )} + hasSplitGroups={groups.length > 1} onFocusOwningGroup={focusOwningGroup} /> ))} From ee291640cad973a14d5c02b87c3f3df0e36388ff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:56:24 -0700 Subject: [PATCH 5/7] test(chat): verify split opacity follows the focused group --- ...ructuredAgentSessionPaneOverlayLayer.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx index a95dfee553ac..de27f7a6d1a1 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx @@ -2,9 +2,11 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { resolvePaneStyleOptions } from '@/lib/terminal-theme' import type { Tab, TabGroup } from '../../../../shared/tab-types' type MockAppState = { + settings?: Parameters[0] unifiedTabsByWorktree: Record groupsByWorktree: Record activeGroupIdByWorktree: Record @@ -216,6 +218,13 @@ 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 first = chatSurface(view.container, FIRST_TAB_ID).parentElement + const second = chatSurface(view.container, SECOND_TAB_ID).parentElement + expect(first?.style.opacity).toBe('0.5') + 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('0.5') }) it('marks no column as focused when the focused group id names nothing', () => { @@ -233,6 +242,13 @@ describe('StructuredAgentSessionPaneOverlayLayer', () => { function createState(activeTabId: string): MockAppState { return { + settings: { + terminalInactivePaneOpacity: 0.5, + terminalActivePaneOpacity: 1, + terminalPaneOpacityTransitionMs: 120, + terminalDividerThicknessPx: 1, + terminalFocusFollowsMouse: false + }, unifiedTabsByWorktree: { [WORKTREE_ID]: [ structuredTab(FIRST_TAB_ID, 'session-1', 0), From 4460fe41ffa227e1f3cbb7e4126b28ac4927f697 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:40:37 -0700 Subject: [PATCH 6/7] 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. --- ...turedAgentSessionPaneOverlayLayer.test.tsx | 19 ++++++----------- ...StructuredAgentSessionPaneOverlayLayer.tsx | 21 +++++++------------ .../components/tab-group/TabGroupPanel.tsx | 3 ++- .../components/tab-group/split-group-dim.ts | 2 ++ 4 files changed, 17 insertions(+), 28 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 de27f7a6d1a1..e052e58d6b17 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx @@ -2,11 +2,10 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { resolvePaneStyleOptions } from '@/lib/terminal-theme' +import { UNFOCUSED_SPLIT_GROUP_DIM_CLASS } from '../tab-group/split-group-dim' import type { Tab, TabGroup } from '../../../../shared/tab-types' type MockAppState = { - settings?: Parameters[0] unifiedTabsByWorktree: Record groupsByWorktree: Record activeGroupIdByWorktree: Record @@ -220,11 +219,12 @@ describe('StructuredAgentSessionPaneOverlayLayer', () => { expect(chatSurface(view.container, SECOND_TAB_ID).dataset.chatFocusedGroup).toBe('true') const first = chatSurface(view.container, FIRST_TAB_ID).parentElement const second = chatSurface(view.container, SECOND_TAB_ID).parentElement - expect(first?.style.opacity).toBe('0.5') - expect(second?.style.opacity).toBe('1') + // The unfocused chat takes the same dim as every other unfocused split group, never more. + expect(first?.classList.contains(UNFOCUSED_SPLIT_GROUP_DIM_CLASS)).toBe(true) + expect(second?.classList.contains(UNFOCUSED_SPLIT_GROUP_DIM_CLASS)).toBe(false) act(() => mocks.store?.setState({ activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID } })) - expect(first?.style.opacity).toBe('1') - expect(second?.style.opacity).toBe('0.5') + expect(first?.classList.contains(UNFOCUSED_SPLIT_GROUP_DIM_CLASS)).toBe(false) + expect(second?.classList.contains(UNFOCUSED_SPLIT_GROUP_DIM_CLASS)).toBe(true) }) it('marks no column as focused when the focused group id names nothing', () => { @@ -242,13 +242,6 @@ describe('StructuredAgentSessionPaneOverlayLayer', () => { function createState(activeTabId: string): MockAppState { return { - settings: { - terminalInactivePaneOpacity: 0.5, - terminalActivePaneOpacity: 1, - terminalPaneOpacityTransitionMs: 120, - terminalDividerThicknessPx: 1, - terminalFocusFollowsMouse: false - }, unifiedTabsByWorktree: { [WORKTREE_ID]: [ structuredTab(FIRST_TAB_ID, 'session-1', 0), diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx index 7c4c4a95621a..6d20f2f38cbb 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx @@ -9,7 +9,8 @@ import { import { RetainedPaneHost } from '../tab-group/RetainedPaneHost' import NativeChatView from './NativeChatView' import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' -import { resolvePaneStyleOptions } from '@/lib/terminal-theme' +import { cn } from '@/lib/utils' +import { UNFOCUSED_SPLIT_GROUP_DIM_CLASS } from '../tab-group/split-group-dim' type StructuredAgentSessionTab = Tab & { contentType: 'agent-session' @@ -38,8 +39,6 @@ const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOv // hosts can share. const owner = useAppStore((state) => structuredAgentSessionOwnerForTab(state, tab)) const target = useMemo(() => structuredAgentSessionTargetForHost(owner), [owner]) - const settings = useAppStore((state) => state.settings) - const paneStyle = settings ? resolvePaneStyleOptions(settings) : undefined if (!target) { return null } @@ -50,18 +49,12 @@ const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOv data-structured-agent-session-overlay-tab-id={tab.id} onFocusOwningGroup={onFocusOwningGroup} > + {/* The chat layer sits outside its group's DOM, so it takes the group's dim itself. */}
import('../editor/EditorPanel')) const EMPTY_GROUPS: readonly TabGroup[] = [] @@ -259,7 +260,7 @@ export default function TabGroupPanel({ touchesBottomEdge || suppressBottomBorder ? '' : 'border-b' } border-border ${ isFocused && !touchesBottomEdge && !suppressBottomBorder ? 'border-b-accent' : '' - } ${isFocused ? '' : 'opacity-95'}` + } ${isFocused ? '' : UNFOCUSED_SPLIT_GROUP_DIM_CLASS}` : '' }`} onPointerDown={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..56e25139ec38 --- /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_DIM_CLASS = 'opacity-95' From a881dcf84dbf6c004a49bc62e5161b3a7b1f8c36 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:51:02 -0700 Subject: [PATCH 7/7] 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. --- ...turedAgentSessionPaneOverlayLayer.test.tsx | 17 +++++----- ...StructuredAgentSessionPaneOverlayLayer.tsx | 31 +++++++------------ .../components/tab-group/RetainedPaneHost.tsx | 11 +++++-- .../components/tab-group/TabGroupPanel.tsx | 5 +-- .../components/tab-group/split-group-dim.ts | 2 +- .../terminal-pane/TerminalOverlaySlot.tsx | 4 +++ .../TerminalPaneOverlayLayer.tsx | 1 + 7 files changed, 38 insertions(+), 33 deletions(-) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx index e052e58d6b17..053ef56e19bd 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { UNFOCUSED_SPLIT_GROUP_DIM_CLASS } from '../tab-group/split-group-dim' +import { UNFOCUSED_SPLIT_GROUP_OPACITY } from '../tab-group/split-group-dim' import type { Tab, TabGroup } from '../../../../shared/tab-types' type MockAppState = { @@ -217,14 +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 first = chatSurface(view.container, FIRST_TAB_ID).parentElement - const second = chatSurface(view.container, SECOND_TAB_ID).parentElement + 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?.classList.contains(UNFOCUSED_SPLIT_GROUP_DIM_CLASS)).toBe(true) - expect(second?.classList.contains(UNFOCUSED_SPLIT_GROUP_DIM_CLASS)).toBe(false) + expect(first?.style.opacity).toBe(dim) + expect(second?.style.opacity).toBe('1') act(() => mocks.store?.setState({ activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID } })) - expect(first?.classList.contains(UNFOCUSED_SPLIT_GROUP_DIM_CLASS)).toBe(false) - expect(second?.classList.contains(UNFOCUSED_SPLIT_GROUP_DIM_CLASS)).toBe(true) + 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 6d20f2f38cbb..5cf531eba85b 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx @@ -9,8 +9,6 @@ import { import { RetainedPaneHost } from '../tab-group/RetainedPaneHost' import NativeChatView from './NativeChatView' import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' -import { cn } from '@/lib/utils' -import { UNFOCUSED_SPLIT_GROUP_DIM_CLASS } from '../tab-group/split-group-dim' type StructuredAgentSessionTab = Tab & { contentType: 'agent-session' @@ -46,27 +44,20 @@ const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOv - {/* The chat layer sits outside its group's DOM, so it takes the group's dim itself. */} -
- -
+
) }) 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 8e86f6389381..6e623c144821 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -20,7 +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_DIM_CLASS } from './split-group-dim' +import { UNFOCUSED_SPLIT_GROUP_OPACITY } from './split-group-dim' const EditorPanel = lazy(() => import('../editor/EditorPanel')) const EMPTY_GROUPS: readonly TabGroup[] = [] @@ -260,9 +260,10 @@ export default function TabGroupPanel({ touchesBottomEdge || suppressBottomBorder ? '' : 'border-b' } border-border ${ isFocused && !touchesBottomEdge && !suppressBottomBorder ? 'border-b-accent' : '' - } ${isFocused ? '' : UNFOCUSED_SPLIT_GROUP_DIM_CLASS}` + }` : '' }`} + 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 index 56e25139ec38..ccc1669f584d 100644 --- a/src/renderer/src/components/tab-group/split-group-dim.ts +++ b/src/renderer/src/components/tab-group/split-group-dim.ts @@ -1,2 +1,2 @@ /** The one dim every unfocused split group's content gets, terminals, editors and chats alike. */ -export const UNFOCUSED_SPLIT_GROUP_DIM_CLASS = 'opacity-95' +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}