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>(