diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 81e8d9a22ed..d7ca6a0da5a 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -146,8 +146,6 @@ export function NativeChatStructuredSession( startFailures, agentName: agentLabel }) - // Nothing reads an unread history, so its pane stays blank beside the Retry line. - const loadingPane = historyPhase === 'unread' ? null : // A lost contact is the host notice's to say; the read adds only a refusal the host sent. const readFailure = controller.status === 'error' && !(hostOutage && !controller.readRefusal) @@ -170,6 +168,7 @@ export function NativeChatStructuredSession( const fileLinkProps = { onLinkClick, allowFileUriLinks: onLinkClick !== undefined } const paneCommands = useStructuredNativeChatPaneCommands({ tabId: props.tabId, + ownerWorktreeId, groupId: props.groupId, isVisible: props.isVisible, rootRef, @@ -294,7 +293,7 @@ export function NativeChatStructuredSession( {find.isOpen ? : null} {viewState.kind === 'loading' || (viewState.kind === 'error' && !readFailure) ? ( - loadingPane + historyPhase !== 'unread' && ) : viewState.kind === 'error' ? ( ({ runNativeChatSplitTarget: vi.fn(() => true) })) +vi.mock('./NativeChatSelectionQuote', () => ({ NativeChatSelectionQuote: () => null })) +const initial = useAppStore.getState() +let reads = 0 +let renders = 0 +let revision = 0 +afterEach(() => { + cleanup() + useAppStore.setState(initial, true) + vi.clearAllMocks() +}) +function Host({ index }: { index: number }) { + renders++ + const rootRef = createRef() + const { menu, ...commands } = useStructuredNativeChatPaneCommands({ + tabId: `chat-${index}`, + groupId: 'group-owner', + ownerWorktreeId: 'owner', + isVisible: index % 3 !== 1, + terminalPaneActions: index % 3 === 2 ? emptyNativeChatContextMenuActions : undefined, + rootRef, + composerRef: createRef(), + questionAnswerInputRef: createRef(), + sessionId: '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37', + target: { kind: 'local' } + }) + return ( +
+ {menu} +
+ ) +} +function measure(write: () => void) { + for (const tab of Object.values(useAppStore.getState().unifiedTabsByWorktree).flat()) { + for (const key of ['id', 'groupId'] as const) { + const value = tab[key] + Object.defineProperty(tab, key, { + configurable: true, + enumerable: true, + get: () => (reads++, value) + }) + } + } + reads = 0 + act(write) + return reads +} + +it.each([6, 60])('bounds retained menu work at %i chats', async (size) => { + const seed = () => { + const tabs = Array.from({ length: size }, (_, index) => ({ + ...makeUnifiedTab(`chat-${index}`, 'owner', `session-${index}`, 'Chat'), + contentType: 'agent-session' as const, + agentSessionAgent: 'codex' as const + })) + const ids = tabs.map((tab) => tab.id) + const other = { ...tabs[0], id: 'other', worktreeId: 'other', groupId: 'group-other' } + useAppStore.setState({ + keybindings: initial.keybindings, + tabsByWorktree: {}, + unifiedTabsByWorktree: { owner: tabs, other: [other] }, + groupsByWorktree: { + owner: [makeGroup('owner', ids)], + other: [makeGroup('other', ['other'])] + } + }) + } + const writes = [ + () => useAppStore.getState().setTabLabel('chat-1', `Title ${++revision}`), + () => useAppStore.getState().activateTab('chat-1'), + () => useAppStore.getState().setTabLabel('other', `Other ${++revision}`), + () => useAppStore.getState().activateTab('other'), + () => useAppStore.setState({ activeWorktreeId: 'unrelated' }) + ] + seed() + const baseline = writes.map(measure) + const idle = () => { + act(seed) + const before = renders + expect(writes.map(measure)).toEqual(baseline) + expect(renders).toBe(before) + } + const hosts = Array.from({ length: size }, (_, index) => ) + render(hosts, { wrapper: TooltipProvider }) + idle() + fireEvent.contextMenu(screen.getByTestId('host-0')) + await screen.findByRole('menu') + expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual([ + expect.stringContaining('Split Chat Right'), + expect.stringContaining('Split Chat Down'), + 'Copy Orca Session ID' + ]) + const opened = renders + const extra = measure(writes[0]) - baseline[0] + expect(extra).toBeGreaterThanOrEqual(size - 1) + expect(extra).toBeLessThanOrEqual(size * 2) + expect(renders).toBe(opened) + await new Promise((resolve) => setTimeout(resolve, 110)) + fireEvent.click(screen.getByRole('menuitem', { name: /Split Chat Right/ })) + expect(runNativeChatSplitTarget).toHaveBeenLastCalledWith( + { kind: 'workspace-tab', unifiedTabId: 'chat-0', groupId: 'group-owner' }, + 'right' + ) + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()) + idle() +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx index 245086f68f6..7ec7982cd3e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx @@ -70,10 +70,6 @@ vi.mock('@/lib/image-blob-png', async (importOriginal) => ({ vi.mock('./NativeChatSelectionQuote', () => ({ NativeChatSelectionQuote: () => null })) -vi.mock('@/components/tab-bar/TabWorkspaceLayoutMenuSection', () => ({ - TabWorkspaceLayoutMenuSection: () => 'Move Tab to Split' -})) - function childrenText(children: ReactNode): string { return React.Children.toArray(children) .map((child) => { @@ -102,7 +98,6 @@ function Harness({ composerRef: createRef(), onSwitchToTerminal, showTerminalPaneActions: !structured, - workspaceLayout: structured ? { unifiedTabId: 'chat-tab', groupId: 'group-1' } : undefined, resolveOrcaSessionId: orcaSessionId === undefined ? undefined : async () => orcaSessionId, actions: { ...emptyNativeChatContextMenuActions, @@ -424,14 +419,6 @@ describe('useNativeChatContextMenu', () => { ).toBe(false) }) - it('reuses workspace layout actions without terminal-only pane commands', () => { - const markup = renderToStaticMarkup() - - expect(markup).toContain('Move Tab to Split') - expect(markup).not.toContain('Split Terminal Right') - expect(markup).not.toContain('Fork Agent Session') - }) - describe('Copy Orca Session ID', () => { const orcaSessionId = 'orca_session_id:4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' const writeClipboardText = vi.fn() diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 48c8d192bc7..5153434f767 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -7,7 +7,16 @@ import { type PointerEventHandler, type RefObject } from 'react' -import { Clipboard, Copy, GitFork, Image as ImageIcon, SquareTerminal } from 'lucide-react' +import { + Clipboard, + Copy, + GitFork, + Image as ImageIcon, + PanelBottomClose, + PanelRightClose, + SquareTerminal, + X +} from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -23,15 +32,14 @@ import { type NativeChatCopyImage } from './native-chat-image-copy' import { isMacPlatform, nativeChatToggleShortcutLabel } from './native-chat-shortcut' -import { TabWorkspaceLayoutMenuSection } from '@/components/tab-bar/TabWorkspaceLayoutMenuSection' -import { canMoveTabToNewPaneColumn } from '@/components/tab-bar/tab-move-to-pane-column' +import { useAppStore } from '@/store' +import { useStructuredChatPaneActions } from './use-structured-chat-pane-actions' import { isEditableTarget } from '@/lib/editable-target' import { findEditablePrimarySelectionPasteTarget } from '@/lib/primary-selection-paste' import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSessionIdMenuItem' import { NativeChatSelectionQuote } from './NativeChatSelectionQuote' import { NativeChatPaneMenuItems } from './NativeChatPaneMenuItems' import type { NativeChatComposerHandle } from './native-chat-composer-types' -import type { TabSplitDirection } from '@/store/slices/tabs' import { AgentSessionContinuationMenuItem } from '@/components/terminal-pane/AgentSessionContinuationMenuItem' import { AgentSessionContextCopyMenuItem } from '@/components/terminal-pane/AgentSessionContextCopyMenuItem' import { @@ -67,11 +75,7 @@ type UseNativeChatContextMenuArgs = { actions: NativeChatContextMenuActions showTerminalPaneActions?: boolean splitShortcutLabels?: { right: string; down: string } - workspaceLayout?: { - unifiedTabId: string - groupId: string - shortcutLabels?: Partial> - } + chatPane?: { tabId: string; worktreeId: string; groupId: string } /** A structured chat tab's Orca session ID; a chat in a terminal pane is that terminal's agent. */ resolveOrcaSessionId?: () => Promise } & NativeChatContextMenuTargetArgs @@ -142,7 +146,7 @@ export function useNativeChatContextMenu({ actions, showTerminalPaneActions = true, splitShortcutLabels, - workspaceLayout, + chatPane, resolveOrcaSessionId, messageListRef, fileLinkContext = null, @@ -173,11 +177,8 @@ export function useNativeChatContextMenu({ Boolean(target.quoteText ?? target.link ?? target.codeText ?? target.messageText) || (target.rewindItemId !== null && rewind !== undefined) const hasSessionItems = onFind !== undefined || showTerminalPaneActions - const showWorkspaceLayout = - workspaceLayout !== undefined && - canMoveTabToNewPaneColumn(workspaceLayout.unifiedTabId, workspaceLayout.groupId) const hasItems = - hasEditItems || hasSessionItems || showWorkspaceLayout || resolveOrcaSessionId !== undefined + hasEditItems || hasSessionItems || chatPane !== undefined || resolveOrcaSessionId !== undefined useEffect(() => { if (!enabled) { @@ -317,21 +318,19 @@ export function useNativeChatContextMenu({ ) : null} - {workspaceLayout ? ( - ) : null} {showTerminalPaneActions ? ( - ) : resolveOrcaSessionId ? ( + ) : !chatPane && resolveOrcaSessionId ? ( <> - {hasEditItems || hasSessionItems || showWorkspaceLayout ? ( - - ) : null} + {hasEditItems || hasSessionItems ? : null} ) : null} @@ -341,6 +340,51 @@ export function useNativeChatContextMenu({ } } +function StructuredChatPaneMenuItems({ + pane, + leadingSeparator, + splitShortcutLabels, + resolveOrcaSessionId +}: Pick & { + pane: NonNullable + leadingSeparator: boolean +}) { + const actions = useStructuredChatPaneActions(pane.tabId, pane.worktreeId, pane.groupId) + const hasSplitPanes = useAppStore( + (state) => (state.groupsByWorktree[pane.worktreeId]?.length ?? 0) > 1 + ) + return ( + <> + {leadingSeparator ? : null} + + + {translate('auto.components.cmd.j.quick.actions.splitChatRight', 'Split Chat Right')} + {splitShortcutLabels?.right} + + + + {translate('auto.components.cmd.j.quick.actions.splitChatDown', 'Split Chat Down')} + {splitShortcutLabels?.down} + + {resolveOrcaSessionId ? ( + <> + + + + ) : null} + {hasSplitPanes && actions.canClosePane ? ( + <> + + + + {actions.closeLabel} + + + ) : null} + + ) +} + function keepSelectionThroughMenuPress(event: React.PointerEvent): void { // Why: left alone, the press that opens the menu collapses the selection before contextmenu fires. if ( diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx index 758ddbb1f29..1d3a8e06fab 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx @@ -7,12 +7,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as I18n from '@/i18n/i18n' import type { NativeChatComposerHandle } from './NativeChatComposer' import { useStructuredNativeChatPaneCommands } from './use-structured-native-chat-pane-commands' -import { moveTabToNewPaneColumn } from '@/components/tab-bar/tab-move-to-pane-column' type ItemProps = { onSelect?: () => void; children?: ReactNode } const items = vi.hoisted(() => ({ list: [] as ItemProps[] })) const callRuntimeRpc = vi.hoisted(() => vi.fn()) +const paneActions = vi.hoisted(() => ({ + onSplitRight: vi.fn(() => true), + onSplitDown: vi.fn(() => true), + canClosePane: true, + closeLabel: 'Close Pane', + onClosePane: vi.fn() +})) +const store = vi.hoisted(() => ({ + keybindings: {}, + groupsByWorktree: { 'worktree-1': [{ id: 'group-1' }] } +})) vi.mock('@/components/ui/dropdown-menu', () => { const Pass = ({ children }: { children?: ReactNode }) => children @@ -41,12 +51,11 @@ vi.mock('@/components/ui/tooltip', () => { const Pass = ({ children }: { children?: ReactNode }) => children return { Tooltip: Pass, TooltipTrigger: Pass, TooltipContent: () => null } }) -vi.mock('@/components/tab-bar/tab-move-to-pane-column', () => ({ - canMoveTabToNewPaneColumn: () => true, - moveTabToNewPaneColumn: vi.fn() +vi.mock('./use-structured-chat-pane-actions', () => ({ + useStructuredChatPaneActions: () => paneActions })) vi.mock('@/store', () => ({ - useAppStore: (select: (state: { keybindings: object }) => unknown) => select({ keybindings: {} }) + useAppStore: (select: (state: typeof store) => unknown) => select(store) })) vi.mock('../../runtime/runtime-rpc-client', async (importOriginal) => ({ ...(await importOriginal>()), @@ -68,9 +77,10 @@ function childrenText(children: ReactNode): string { .join('') } -function StructuredChatTab(): ReactNode { +function StructuredChatTab({ owner = 'worktree-1' }: { owner?: string | null }): ReactNode { const { menu } = useStructuredNativeChatPaneCommands({ tabId: 'chat-tab', + ownerWorktreeId: owner, groupId: 'group-1', isVisible: true, rootRef: createRef(), @@ -85,23 +95,41 @@ function StructuredChatTab(): ReactNode { describe('a structured chat tab', () => { beforeEach(() => { items.list = [] + vi.clearAllMocks() + store.groupsByWorktree['worktree-1'] = [{ id: 'group-1' }] callRuntimeRpc.mockReset().mockResolvedValue({ orcaSessionId: `orca_session_id:${SESSION}` }) Object.assign(window, { api: { ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } } }) }) - it('keeps Move Tab to Split without fresh-chat shortcut hints', () => { - renderToStaticMarkup() - const right = items.list.find((item) => childrenText(item.children) === 'Right') - expect(right).toBeDefined() - expect(items.list.some((item) => childrenText(item.children) === 'Down')).toBe(true) - right?.onSelect?.() - expect(moveTabToNewPaneColumn).toHaveBeenCalledWith({ - unifiedTabId: 'chat-tab', - groupId: 'group-1', - direction: 'right' - }) + it.each([false, true])('uses the header commands with split panes: %s', (split) => { + if (split) { + store.groupsByWorktree['worktree-1'].push({ id: 'group-2' }) + } + const markup = renderToStaticMarkup() + expect(markup).not.toContain('Move Tab to Split') + expect(markup).not.toContain('Resume in New CLI') + expect(markup).not.toContain('Continue with Terminal Agent') + for (const [label, command] of [ + ['Split Chat Right', paneActions.onSplitRight], + ['Split Chat Down', paneActions.onSplitDown] + ] as const) { + const item = items.list.find((entry) => childrenText(entry.children).startsWith(label)) + expect(item).toBeDefined() + item?.onSelect?.() + expect(command).toHaveBeenCalledOnce() + } + const close = items.list.find((entry) => childrenText(entry.children) === 'Close Pane') + expect(Boolean(close)).toBe(split) + close?.onSelect?.() + expect(paneActions.onClosePane).toHaveBeenCalledTimes(split ? 1 : 0) + }) + + it.each([null, ''])('keeps the session-ID fallback without a known owner: %s', (owner) => { + const markup = renderToStaticMarkup() + expect(markup).not.toContain('Split Chat') + expect(markup).toContain('Copy Orca Session ID') }) it("offers Copy Orca Session ID, asking the tab's host for this session's Orca session ID", async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.ts index c89bb0b3c38..893490e4564 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.ts +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.ts @@ -18,6 +18,7 @@ import { runNativeChatSplitTarget } from './native-chat-layout-actions' export function useStructuredNativeChatPaneCommands({ tabId, + ownerWorktreeId, groupId, isVisible, rootRef, @@ -29,6 +30,7 @@ export function useStructuredNativeChatPaneCommands({ ...menuTargets }: { tabId: string + ownerWorktreeId: string | null groupId?: string isVisible: boolean rootRef: RefObject @@ -67,12 +69,9 @@ export function useStructuredNativeChatPaneCommands({ right: formatShortcutLabel('terminal.splitRight', keybindings), down: formatShortcutLabel('terminal.splitDown', keybindings) }, - workspaceLayout: - groupId && terminalPaneActions === undefined - ? { - unifiedTabId: tabId, - groupId - } + chatPane: + ownerWorktreeId && groupId && terminalPaneActions === undefined + ? { tabId, worktreeId: ownerWorktreeId, groupId } : undefined }) const onKeyDownCapture = useCallback>(