Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
f086c3a
Transfer keyboard focus when a sibling chat pane opens
brennanb2025 Oct 15, 2026
0ed5bb4
fix(native-chat): hand focus to newly opened chat composer
brennanb2025 Oct 15, 2026
6167d52
fix(native-chat): keep tab bar controller within line limit
brennanb2025 Oct 15, 2026
a66078c
fix(chat): split into a fresh conversation beside the current chat
brennanb2025 Oct 15, 2026
7a09e4a
test(chat): declare the split fixture store without an assertion
brennanb2025 Oct 15, 2026
9895b77
fix(chat): route the split to the source conversation host
brennanb2025 Oct 15, 2026
55b97cf
test(chat): keep the split regression focused on the launch contract
brennanb2025 Oct 15, 2026
64f5843
test(chat): give the command palette split fixture its conversation a…
brennanb2025 Oct 15, 2026
c41c2fa
test(chat): seed the legacy command fixture without expanding its ass…
brennanb2025 Oct 15, 2026
4302dc3
test(chat): isolate pane identity commands from the split launch boun…
brennanb2025 Oct 15, 2026
e70e185
Prevent chat splits from starting a terminal on host refusal
brennanb2025 Oct 15, 2026
90d2d89
Cancel structured launches when their destination pane closes
brennanb2025 Oct 15, 2026
c2fcf4b
Model live destination groups in agent launch test fixture
brennanb2025 Oct 15, 2026
d56006f
Capture split chat focus intent at the user action
brennanb2025 Oct 15, 2026
72dfe8b
feat(chat): add the terminal pane hover action cluster
brennanb2025 Oct 15, 2026
2ceba76
fix(chat): keep pane controls above the conversation surface
brennanb2025 Oct 15, 2026
00bf68c
refactor(chat): share the tab resume launch hook and format pane cont…
brennanb2025 Oct 15, 2026
02dbb1e
style(chat): document reuse of the terminal header button styles
brennanb2025 Oct 15, 2026
7ee4392
refactor(ui): share the pane title button variants between terminals …
brennanb2025 Oct 15, 2026
813208f
fix(chat): keep header actions scoped to the displayed chat
brennanb2025 Oct 15, 2026
591e393
fix(chat): abandon Resume when its destination pane closes
brennanb2025 Oct 15, 2026
fe5d91e
test(chat): declare destination fixtures without type assertions
brennanb2025 Oct 15, 2026
d5dfe40
fix(chat): keep Resume visible when its source pane closes
brennanb2025 Oct 15, 2026
53c3dbe
fix(chat): reuse tab menu Resume placement unchanged
brennanb2025 Oct 15, 2026
81242ac
test: fix Resume regression harness setup
brennanb2025 Oct 15, 2026
9ad697c
feat(chat): match terminal pane split and close context actions
brennanb2025 Oct 15, 2026
5604c45
style(chat): format the pane context menu
brennanb2025 Oct 15, 2026
0d11c53
style(chat): format the structured pane command fixture
brennanb2025 Oct 15, 2026
2c284cd
test(chat): remove the obsolete tab-movement menu stubs
brennanb2025 Oct 15, 2026
5728795
Match terminal close-pane menu styling
brennanb2025 Oct 15, 2026
b7ea11a
fix(chat): share the pane action close label in the body menu
brennanb2025 Oct 15, 2026
419c3b6
test(chat): match structured pane action fixture returns
brennanb2025 Oct 15, 2026
ca269e1
fix(chat): hand input to the split pane during admission
Oct 15, 2026
8fe8cd3
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 21, 2026
8cb9fd3
fix(chat): Split opens a fresh chat beside the current one
brennanb2025 Oct 21, 2026
46dfe6a
fix(chat): Split palette search words describe a new chat, not a move
brennanb2025 Oct 21, 2026
d3b4c75
fix: remove misleading chat move shortcut hints
brennanb2025 Oct 21, 2026
b904956
fix(chat): a new chat stays in its pane until its host lists it
brennanb2025 Oct 21, 2026
e64c5c3
test(chat): type the faked create replies without an assertion
brennanb2025 Oct 21, 2026
b2c663c
Keep new chat protection tied to accepted host lists
brennanb2025 Oct 21, 2026
8abd33c
Update paired chat close regression to current suppression API
brennanb2025 Oct 21, 2026
e23b07a
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 21, 2026
5808d99
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 21, 2026
267a686
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 21, 2026
e382d82
fix(chat): Portuguese Split palette words describe a new chat
brennanb2025 Oct 21, 2026
3687253
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 22, 2026
9551884
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 22, 2026
7d78d91
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 22, 2026
29731df
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 22, 2026
ba54568
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 22, 2026
9c3ec3f
Merge current split chat base and reuse its pane actions
brennanb2025 Oct 22, 2026
649d734
test(chat): use the destination group option in split fixture
brennanb2025 Oct 22, 2026
1d25bcc
Avoid History projection in native chat header and currentness checks
brennanb2025 Oct 22, 2026
f0e90c8
fix(native-chat): keep Find below pane controls
brennanb2025 Oct 22, 2026
8391b9f
fix(ui): keep noninteractive tooltip layers pointer-transparent
brennanb2025 Oct 22, 2026
91dcc66
Merge main into native chat pane actions
brennanb2025 Oct 22, 2026
1d94509
Merge current native chat pane actions into body menu
brennanb2025 Oct 23, 2026
f631ee2
fix: derive chat pane menu state inside mounted content
brennanb2025 Oct 23, 2026
053a963
test: use complete settings fixture for chat menu contract
brennanb2025 Oct 23, 2026
dfdbc17
ci: refresh chat menu checks after base correction
brennanb2025 Oct 23, 2026
5bba8c5
ci: refresh chat menu checks after base type fixes
brennanb2025 Oct 23, 2026
3e5a901
Merge main into native chat pane menu
brennanb2025 Oct 23, 2026
4652a76
test(chat): focus retained menu lifetime contract
brennanb2025 Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 : <NativeChatLoadingCue />
// 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)
Expand All @@ -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,
Expand Down Expand Up @@ -294,7 +293,7 @@ export function NativeChatStructuredSession(
<NativeChatContextClearFade clearing={controller.contextClear.clearing}>
{find.isOpen ? <NativeChatFindBar find={find} isVisible={props.isVisible} /> : null}
{viewState.kind === 'loading' || (viewState.kind === 'error' && !readFailure) ? (
loadingPane
historyPhase !== 'unread' && <NativeChatLoadingCue />
) : viewState.kind === 'error' ? (
<NativeChatEmptyState
kind="error"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// @vitest-environment happy-dom
import { createRef } from 'react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { TooltipProvider } from '@/components/ui/tooltip'
import { makeGroup, makeUnifiedTab } from '../worktree-jump-palette-test-fixtures'
import { useStructuredNativeChatPaneCommands } from './use-structured-native-chat-pane-commands'
import { emptyNativeChatContextMenuActions } from './use-native-chat-context-menu'
import { runNativeChatSplitTarget } from './native-chat-layout-actions'

vi.mock('./native-chat-layout-actions', () => ({ 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<HTMLDivElement>()
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 (
<div ref={rootRef} data-testid={`host-${index}`} {...commands}>
{menu}
</div>
)
}
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) => <Host key={index} index={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()
})
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -424,14 +419,6 @@ describe('useNativeChatContextMenu', () => {
).toBe(false)
})

it('reuses workspace layout actions without terminal-only pane commands', () => {
const markup = renderToStaticMarkup(<Harness structured />)

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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -67,11 +75,7 @@ type UseNativeChatContextMenuArgs = {
actions: NativeChatContextMenuActions
showTerminalPaneActions?: boolean
splitShortcutLabels?: { right: string; down: string }
workspaceLayout?: {
unifiedTabId: string
groupId: string
shortcutLabels?: Partial<Record<TabSplitDirection, string>>
}
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<string | null>
} & NativeChatContextMenuTargetArgs
Expand Down Expand Up @@ -142,7 +146,7 @@ export function useNativeChatContextMenu({
actions,
showTerminalPaneActions = true,
splitShortcutLabels,
workspaceLayout,
chatPane,
resolveOrcaSessionId,
messageListRef,
fileLinkContext = null,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -317,21 +318,19 @@ export function useNativeChatContextMenu({
<AgentSessionContextCopyMenuItem onSelect={actions.onCopyAgentSessionContext} />
</>
) : null}
{workspaceLayout ? (
<TabWorkspaceLayoutMenuSection
unifiedTabId={workspaceLayout.unifiedTabId}
groupId={workspaceLayout.groupId}
{chatPane ? (
<StructuredChatPaneMenuItems
pane={chatPane}
leadingSeparator={hasEditItems || hasSessionItems}
shortcutLabels={workspaceLayout.shortcutLabels}
splitShortcutLabels={splitShortcutLabels}
resolveOrcaSessionId={resolveOrcaSessionId}
/>
) : null}
{showTerminalPaneActions ? (
<NativeChatPaneMenuItems actions={actions} splitShortcutLabels={splitShortcutLabels} />
) : resolveOrcaSessionId ? (
) : !chatPane && resolveOrcaSessionId ? (
<>
{hasEditItems || hasSessionItems || showWorkspaceLayout ? (
<DropdownMenuSeparator />
) : null}
{hasEditItems || hasSessionItems ? <DropdownMenuSeparator /> : null}
<NativeChatCopyOrcaSessionIdMenuItem resolveOrcaSessionId={resolveOrcaSessionId} />
</>
) : null}
Expand All @@ -341,6 +340,51 @@ export function useNativeChatContextMenu({
}
}

function StructuredChatPaneMenuItems({
pane,
leadingSeparator,
splitShortcutLabels,
resolveOrcaSessionId
}: Pick<UseNativeChatContextMenuArgs, 'splitShortcutLabels' | 'resolveOrcaSessionId'> & {
pane: NonNullable<UseNativeChatContextMenuArgs['chatPane']>
leadingSeparator: boolean
}) {
const actions = useStructuredChatPaneActions(pane.tabId, pane.worktreeId, pane.groupId)
const hasSplitPanes = useAppStore(
(state) => (state.groupsByWorktree[pane.worktreeId]?.length ?? 0) > 1
)
return (
<>
{leadingSeparator ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem onSelect={actions.onSplitRight}>
<PanelRightClose />
{translate('auto.components.cmd.j.quick.actions.splitChatRight', 'Split Chat Right')}
<DropdownMenuShortcut>{splitShortcutLabels?.right}</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.onSplitDown}>
<PanelBottomClose />
{translate('auto.components.cmd.j.quick.actions.splitChatDown', 'Split Chat Down')}
<DropdownMenuShortcut>{splitShortcutLabels?.down}</DropdownMenuShortcut>
</DropdownMenuItem>
{resolveOrcaSessionId ? (
<>
<DropdownMenuSeparator />
<NativeChatCopyOrcaSessionIdMenuItem resolveOrcaSessionId={resolveOrcaSessionId} />
</>
) : null}
{hasSplitPanes && actions.canClosePane ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={actions.onClosePane}>
<X />
{actions.closeLabel}
</DropdownMenuItem>
</>
) : null}
</>
)
}

function keepSelectionThroughMenuPress(event: React.PointerEvent<HTMLElement>): void {
// Why: left alone, the press that opens the menu collapses the selection before contextmenu fires.
if (
Expand Down
Loading
Loading