feat(chat): add terminal-style pane header actions (#28968)

* Transfer keyboard focus when a sibling chat pane opens

* fix(native-chat): hand focus to newly opened chat composer

* fix(native-chat): keep tab bar controller within line limit

* fix(chat): split into a fresh conversation beside the current chat

* test(chat): declare the split fixture store without an assertion

* fix(chat): route the split to the source conversation host

* test(chat): keep the split regression focused on the launch contract

* test(chat): give the command palette split fixture its conversation agent

* test(chat): seed the legacy command fixture without expanding its asserted shape

* test(chat): isolate pane identity commands from the split launch boundary

* Prevent chat splits from starting a terminal on host refusal

* Cancel structured launches when their destination pane closes

* Model live destination groups in agent launch test fixture

* Capture split chat focus intent at the user action

* feat(chat): add the terminal pane hover action cluster

* fix(chat): keep pane controls above the conversation surface

* refactor(chat): share the tab resume launch hook and format pane controls

* style(chat): document reuse of the terminal header button styles

* refactor(ui): share the pane title button variants between terminals and chats

* fix(chat): keep header actions scoped to the displayed chat

* fix(chat): abandon Resume when its destination pane closes

* test(chat): declare destination fixtures without type assertions

* fix(chat): keep Resume visible when its source pane closes

* fix(chat): reuse tab menu Resume placement unchanged

* test: fix Resume regression harness setup

* fix(chat): hand input to the split pane during admission

* fix(chat): Split opens a fresh chat beside the current one

Split Right/Down on a native chat used to move the chat into a new pane.
It now opens an empty pane beside the chat and launches a new chat of the
same agent on the same host into it through the New Chat launch, so the
new composer takes focus at once. A launch that never opens a tab closes
the empty pane. Drops the earlier admission and focus-transfer changes
that main now covers.

* fix(chat): Split palette search words describe a new chat, not a move

* fix: remove misleading chat move shortcut hints

* fix(chat): a new chat stays in its pane until its host lists it

A host tab list built before a chat's create landed can arrive after the
create reply. Once the reply settled the launch, nothing protected the new
tab, so that older list dropped it: Split's new pane collapsed and focus
moved to another chat, and the next list re-added the chat elsewhere.

Keep a small in-memory record for a chat this client created that no host
list has shown yet. While it exists, a list that omits the chat keeps its
tab. The record ends when an accepted list from that host includes the
chat, when the tab closes, on workspace teardown, or 30 seconds after the
reply, when the worktree is re-read from its host (including an unchanged
version) so a chat the host really closed is dropped then. The launch
lifecycle, status, persistence and settlement are unchanged.

* test(chat): type the faked create replies without an assertion

* Keep new chat protection tied to accepted host lists

* Update paired chat close regression to current suppression API

* fix(chat): Portuguese Split palette words describe a new chat

* test(chat): use the destination group option in split fixture

* Avoid History projection in native chat header and currentness checks

* fix(native-chat): keep Find below pane controls

* fix(ui): keep noninteractive tooltip layers pointer-transparent

* Mount native chat actions only for shown panes

* Use checked property access in header instrumentation

* Read Continue eligibility records through workspace selectors

* Cover native header Find clearance and tooltip click passage

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
Brennan Benson
2026-10-23 16:17:28 -07:00
committed by GitHub
co-authored by Orca Worker
parent 6e3f205749
commit d49ad60ead
22 changed files with 1062 additions and 52 deletions
+12 -13
View File
@@ -10,22 +10,22 @@ This is PR 2. [PR 1 (#29810)](https://github.com/stablyai/orca/pull/29810) separ
## Evidence and severity
* Confirmed pre-change mechanism: `shouldSeedWorktreePrimaryTerminal` treated split setup as a reason to create a shell even when the caller supplies native chat. Renderer `queueSetupAndIssueCommands`, runtime `provisionWorktreeTerminals`, and `spawnLocalStartupAndSetupTerminals` in `worktree-remote.ts` each branched on setup location. This PR reuses their existing dedicated-tab arms.
* The supplied incident investigation observed two leaves bound to one PTY and an independent equal-count chat-publication failure. This worktree does not establish the original missing-binding cause or two setup executions. Removing placement complexity is the user-requested product policy; it is not proof of fixing every frozen terminal.
* No demonstrated P0. P1 acceptance risks are replaying setup after host provisioning, losing requested startup/setup sequencing, or changing primary selection/background workspace selection. The validation below exercises actual creation routes and the preserved one-shot startup mechanisms.
* P2 tradeoff: automatic side-by-side setup placement disappears; manual splitting remains. Older execution hosts can still return existing split layouts, which updated clients must accept without moving/replaying setup.
* The first-terminal delayed delivery coupling exists in `ensureWorktreeHasInitialTerminal` for runtime-owned fallback commands. Ordinary desktop structured creation passes no backend startup and returns setup through local IPC, then uses host authority `none`, so its null-primary Setup path is already independent. The host native-chat route normally consumes setup before returning. Keep delayed delivery unchanged: no actual request proves the alleged native-chat P1. The post-PR-1 semantic review found no reason to expand this scope. The conditional STA-9417 expected-failure fixture does not establish the original incident's cause.
- Confirmed pre-change mechanism: `shouldSeedWorktreePrimaryTerminal` treated split setup as a reason to create a shell even when the caller supplies native chat. Renderer `queueSetupAndIssueCommands`, runtime `provisionWorktreeTerminals`, and `spawnLocalStartupAndSetupTerminals` in `worktree-remote.ts` each branched on setup location. This PR reuses their existing dedicated-tab arms.
- The supplied incident investigation observed two leaves bound to one PTY and an independent equal-count chat-publication failure. This worktree does not establish the original missing-binding cause or two setup executions. Removing placement complexity is the user-requested product policy; it is not proof of fixing every frozen terminal.
- No demonstrated P0. P1 acceptance risks are replaying setup after host provisioning, losing requested startup/setup sequencing, or changing primary selection/background workspace selection. The validation below exercises actual creation routes and the preserved one-shot startup mechanisms.
- P2 tradeoff: automatic side-by-side setup placement disappears; manual splitting remains. Older execution hosts can still return existing split layouts, which updated clients must accept without moving/replaying setup.
- The first-terminal delayed delivery coupling exists in `ensureWorktreeHasInitialTerminal` for runtime-owned fallback commands. Ordinary desktop structured creation passes no backend startup and returns setup through local IPC, then uses host authority `none`, so its null-primary Setup path is already independent. The host native-chat route normally consumes setup before returning. Keep delayed delivery unchanged: no actual request proves the alleged native-chat P1. The post-PR-1 semantic review found no reason to expand this scope. The conditional STA-9417 expected-failure fixture does not establish the original incident's cause.
## Surfaces and selection
| Intent | Surfaces owed by this create with setup |
| --- | --- |
| Native chat | Requested chat plus Setup, without a split-anchor shell. |
| Terminal agent | Requested agent terminal plus Setup. |
| Plain terminal | Normal primary terminal plus Setup. |
| Setup only | Setup alone; preserve current suppression of default tabs. |
| Intent | Surfaces owed by this create with setup |
| --------------------- | -------------------------------------------------------------------- |
| Native chat | Requested chat plus Setup, without a split-anchor shell. |
| Terminal agent | Requested agent terminal plus Setup. |
| Plain terminal | Normal primary terminal plus Setup. |
| Setup only | Setup alone; preserve current suppression of default tabs. |
| Explicit default tabs | Existing template allocation/commands/title/color policy plus Setup. |
| No setup | Existing behavior. |
| No setup | Existing behavior. |
Issue automation retains its independent split from the normal primary pane, command/env, one-shot behavior, and focus policy. Setup stays unselected when another primary surface exists. Setup-only can select Setup when creation is still in view. Background creates/navigation away preserve workspace/tab selection and never reveal/focus a native window.
@@ -59,7 +59,6 @@ Rendered checks require rebuilt hidden Electron plus Playwright CDP; inspect tab
After implementation: Astra reviews actual final diff/tests and semantic overlap after PR 1 rebase. No merge until PR 1 confirmation, exact-head full `.github/workflows/pr.yml` successful aggregate `verify` / PR checks passed and all required checks, and parent approval through the mandatory blocking merge-gate ask. Use `--match-head-commit` and confirm the merge.
## Implemented validation record
Reviewed source head: `d96ae2ca6afdca919b4d7f8d85308363abe8b28b`, based on the exact PR 1 merge above. The follow-up documentation commit changes no source behavior. The independent review records no unresolved P0/P1 and explicit P2 tradeoffs.
+6
View File
@@ -960,6 +960,12 @@ html.native-shell .app-layout {
-webkit-app-region: no-drag;
}
/* Modal inline pointer styles must not turn noninteractive labels into hit targets. */
[data-radix-popper-content-wrapper]:has(> [data-slot='tooltip-content'].pointer-events-none),
[data-slot='tooltip-content'].pointer-events-none {
pointer-events: none !important;
}
.titlebar {
height: 36px;
min-height: 36px;
@@ -44,7 +44,7 @@ export function NativeChatFindBar({
// The chat sends clicks on its plain surface to the composer; the bar's are its own.
data-native-chat-typing-redirect-ignore="true"
data-native-chat-find-bar="true"
className="absolute top-2 right-4 z-20 flex w-85 max-w-[calc(100%-2rem)] items-center gap-1 rounded-lg border border-border bg-popover/95 px-2 py-1 text-popover-foreground shadow-floating backdrop-blur-sm"
className="absolute top-[calc(var(--native-chat-find-top-inset,0px)+var(--spacing)*2)] right-4 z-20 flex w-85 max-w-[calc(100%-2rem)] items-center gap-1 rounded-lg border border-border bg-popover/95 px-2 py-1 text-popover-foreground shadow-floating backdrop-blur-sm"
onMouseDown={(event) => {
// A press on the count or padding keeps typing in the find input.
if (!(event.target instanceof Element) || !event.target.closest('button, input')) {
@@ -0,0 +1,117 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness'
import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures'
import * as historyRequest from '../right-sidebar/ai-vault-panel-session-list-request'
const mocks = vi.hoisted(() => ({ launch: vi.fn(), resume: vi.fn(), close: vi.fn() }))
vi.mock('@/store', async () => {
const { createTestStore } = await import('@/store/slices/store-test-helpers')
return { useAppStore: createTestStore() }
})
vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launch }))
vi.mock('@/lib/workspace-tab-commands', () => ({ dispatchWorkspaceTabCommand: mocks.close }))
vi.mock('../tab-bar/native-chat-cli-fork-run', () => ({
runNativeChatCliFork: mocks.resume,
useNativeChatCliForkPending: () => false
}))
import { NativeChatPaneHeader } from './NativeChatPaneHeader'
const fullRequest = vi.spyOn(historyRequest, 'resolveAiVaultPanelSessionListRequest')
const testWindow = window
const api = createTabsSliceMockApi()
vi.stubGlobal('window', testWindow)
Object.assign(window, { api })
afterEach(cleanup)
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState(useAppStore.getInitialState(), true)
useAppStore.setState({
folderWorkspaces: [makeFolderWorkspace({ id: 'workspace', folderPath: '/repo' })]
})
})
function renderHeader() {
const source = useAppStore.getState().createUnifiedTab('folder:workspace', 'agent-session', {
agentSessionAgent: 'codex',
executionHostId: 'runtime:remote-host'
})
render(
<TooltipProvider>
<NativeChatPaneHeader
tabId={source.id}
worktreeId={source.worktreeId}
groupId={source.groupId}
nativeChat={{ sessionId: source.entityId, agent: 'codex' }}
focused
colorScheme="dark"
/>
</TooltipProvider>
)
return source
}
it('offers the terminal header action order and targets Resume and Close at the displayed chat', () => {
const source = renderHeader()
expect(screen.getAllByRole('button').map((button) => button.getAttribute('aria-label'))).toEqual([
'Continue with Terminal Agent',
'Split Chat Right',
'Close tab'
])
const folder = useAppStore.getState().folderWorkspaces[0]
act(() => useAppStore.setState({ folderWorkspaces: [{ ...folder, executionHostId: 'ssh:dev' }] }))
expect(screen.queryByRole('button', { name: 'Continue with Terminal Agent' })).toBeNull()
act(() => useAppStore.setState({ folderWorkspaces: [folder] }))
expect(fullRequest).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Continue with Terminal Agent' }))
expect(fullRequest).toHaveBeenCalledTimes(1)
expect(mocks.resume).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
subject: expect.objectContaining({
sessionId: source.entityId,
request: { scopePaths: ['/repo'], executionHostScope: 'local', sessionLimit: 250 }
}),
tab: { id: source.id, worktreeId: source.worktreeId },
agent: 'codex'
})
)
fireEvent.click(screen.getByRole('button', { name: 'Close tab' }))
expect(mocks.close).toHaveBeenCalledExactlyOnceWith({
type: 'close',
target: { kind: 'tab', worktreeId: source.worktreeId, tabId: source.id }
})
})
it('Split from the header keeps the source and opens a fresh chat on the same host', () => {
const source = renderHeader()
mocks.launch.mockImplementation((args: { worktreeId: string; groupId: string }) => {
useAppStore.getState().createUnifiedTab(args.worktreeId, 'agent-session', {
targetGroupId: args.groupId,
agentSessionAgent: 'codex',
executionHostId: 'runtime:remote-host'
})
return { surface: { kind: 'local-chat', tabId: 'fresh-chat' } }
})
fireEvent.click(screen.getByRole('button', { name: 'Split Chat Right' }))
const state = useAppStore.getState()
const destination = state.activeGroupIdByWorktree[source.worktreeId]
expect(destination).not.toBe(source.groupId)
expect(mocks.launch).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
agent: 'codex',
requestedSurface: 'chat',
selectedExecutionHostId: source.executionHostId,
worktreeId: source.worktreeId,
groupId: destination,
focusIntent: expect.objectContaining({ cancel: expect.any(Function) })
})
)
const tabs = state.unifiedTabsByWorktree[source.worktreeId]
expect(tabs.find((tab) => tab.id === source.id)).toEqual(source)
expect(tabs).toHaveLength(2)
expect(tabs.find((tab) => tab.groupId === destination)?.entityId).not.toBe(source.entityId)
})
@@ -0,0 +1,124 @@
import { SquareSplitVertical, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { agentForksConversations } from '../../../../shared/agent-resume-argv'
import type { NativeChatTabSession } from '../tab-bar/tab-session-history-switch'
import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark'
import { useStructuredChatPaneActions } from './use-structured-chat-pane-actions'
import { NativeChatResumeInCliButton } from './NativeChatResumeInCliButton'
import type { NativeChatStructuredViewProps } from './native-chat-view-types'
const HEADER_STYLE: React.CSSProperties & Record<'--orca-pane-title-bg', string> = {
'--orca-pane-title-bg': 'var(--chat-canvas)'
}
export function NativeChatPaneHeader({
tabId,
worktreeId,
groupId,
nativeChat,
focused,
colorScheme
}: {
tabId: string
worktreeId: string
groupId: string
nativeChat: NativeChatTabSession
focused: boolean
colorScheme: React.CSSProperties['colorScheme']
}): React.JSX.Element {
const actions = useStructuredChatPaneActions(tabId, worktreeId, groupId)
const theme = useAppStore((state) => state.settings?.theme)
const systemDark = useSystemPrefersDark()
const light = colorScheme
? colorScheme === 'light'
: theme === 'light' || (theme === 'system' && !systemDark)
const split = translate('auto.components.cmd.j.quick.actions.splitChatRight', 'Split Chat Right')
return (
<div
className="pane-title-bar z-20"
data-chromeless=""
data-active-pane={focused ? '' : undefined}
data-pane-title-surface={light ? 'light' : 'dark'}
style={HEADER_STYLE}
>
<div className="pane-title-actions ml-auto flex shrink-0 items-center gap-0">
{nativeChat.agent && agentForksConversations(nativeChat.agent) ? (
<NativeChatResumeInCliButton
tabId={tabId}
worktreeId={worktreeId}
nativeChat={nativeChat}
/>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="pane-title"
size="icon-xs"
aria-label={split}
onClick={(event) => {
event.stopPropagation()
actions.onSplitRight()
}}
>
<SquareSplitVertical className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{split}
</TooltipContent>
</Tooltip>
{actions.canClosePane ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="pane-title-close"
size="icon-xs"
aria-label={actions.closeLabel}
onClick={(event) => {
event.stopPropagation()
actions.onClosePane()
}}
>
<X className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{actions.closeLabel}
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
)
}
export function nativeChatPaneHeader(
view: Pick<
NativeChatStructuredViewProps,
| 'tabId'
| 'groupId'
| 'sessionId'
| 'agent'
| 'isVisible'
| 'isFocusedGroup'
| 'contextMenuActions'
>,
worktreeId: string | null,
colorScheme: React.CSSProperties['colorScheme']
): React.JSX.Element | null {
return view.isVisible && worktreeId && view.groupId && !view.contextMenuActions ? (
<NativeChatPaneHeader
tabId={view.tabId}
worktreeId={worktreeId}
groupId={view.groupId}
nativeChat={{ sessionId: view.sessionId, agent: view.agent }}
focused={view.isFocusedGroup}
colorScheme={colorScheme}
/>
) : null
}
@@ -0,0 +1,112 @@
import { useMemo } from 'react'
import { SquareTerminal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors'
import { translate } from '@/i18n/i18n'
import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types'
import {
runNativeChatCliFork,
useNativeChatCliForkPending
} from '../tab-bar/native-chat-cli-fork-run'
import {
resolveTabSessionHistorySubject,
resolveNativeChatCliForkIdentity,
type NativeChatCliForkIdentity,
type NativeChatTabSession
} from '../tab-bar/tab-session-history-switch'
function createExecutionHostSelector(
tabId: string,
worktreeId: string,
sessionId: string,
agent: NativeChatTabSession['agent']
) {
let worktrees: AppState['worktreesByRepo'] | undefined
let folders: AppState['folderWorkspaces'] | undefined
let groups: AppState['projectGroups'] | undefined
let repos: AppState['repos'] | undefined
let scope: NativeChatCliForkIdentity['executionHostScope'] | null = null
return (state: AppState) => {
const nextWorktrees = selectWorktreesByRepo(state)
const nextFolders = selectFolderWorkspaces(state)
if (
worktrees !== nextWorktrees ||
folders !== nextFolders ||
groups !== state.projectGroups ||
repos !== state.repos
) {
scope =
resolveNativeChatCliForkIdentity(state, {
tab: { id: tabId, worktreeId },
nativeChat: { sessionId, agent }
})?.executionHostScope ?? null
worktrees = nextWorktrees
folders = nextFolders
groups = state.projectGroups
repos = state.repos
}
return scope
}
}
export function NativeChatResumeInCliButton({
tabId,
worktreeId,
nativeChat
}: {
tabId: string
worktreeId: string
nativeChat: NativeChatTabSession
}): React.JSX.Element | null {
const tab = { id: tabId, worktreeId }
const { sessionId, agent: nativeAgent } = nativeChat
const selectExecutionHost = useMemo(
() => createExecutionHostSelector(tabId, worktreeId, sessionId, nativeAgent),
[tabId, worktreeId, sessionId, nativeAgent]
)
const executionHostScope = useAppStore(selectExecutionHost)
const pending = useNativeChatCliForkPending(
executionHostScope ? { executionHostScope, sessionId: nativeChat.sessionId } : null
)
const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides)
const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent)
const label = translate(
'components.tab.bar.SortableTabContextMenu.switchToTerminalView',
'Continue with Terminal Agent'
)
if (!executionHostScope || !agent) {
return null
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="pane-title"
size="icon-xs"
aria-label={label}
disabled={pending}
aria-busy={pending || undefined}
onClick={(event) => {
event.stopPropagation()
const subject = resolveTabSessionHistorySubject(useAppStore.getState(), {
tab,
nativeChat
})
if (subject?.kind === 'chat') {
void runNativeChatCliFork({ subject, tab, agent, commandOverrides })
}
}}
>
<SquareTerminal className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
@@ -51,6 +51,7 @@ import { useAppStore } from '../../store'
import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
import { useNativeChatStructuredComposerTransport } from './use-native-chat-structured-composer-transport'
import { NativeChatStructuredGoalStrip } from './NativeChatStructuredGoalStrip'
import { nativeChatPaneHeader } from './NativeChatPaneHeader'
import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice'
import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices'
import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection'
@@ -166,6 +167,7 @@ export function NativeChatStructuredSession(
rootRef,
{ sessionId: props.sessionId, isVisible: props.isVisible }
)
const fileLinkProps = { onLinkClick, allowFileUriLinks: onLinkClick !== undefined }
const paneCommands = useStructuredNativeChatPaneCommands({
tabId: props.tabId,
groupId: props.groupId,
@@ -288,6 +290,7 @@ export function NativeChatStructuredSession(
style={appearanceStyle}
data-native-chat-scheme={appearanceStyle.colorScheme}
>
{nativeChatPaneHeader(props, ownerWorktreeId, appearanceStyle.colorScheme)}
<NativeChatContextClearFade clearing={controller.contextClear.clearing}>
{find.isOpen ? <NativeChatFindBar find={find} isVisible={props.isVisible} /> : null}
{viewState.kind === 'loading' || (viewState.kind === 'error' && !readFailure) ? (
@@ -323,8 +326,7 @@ export function NativeChatStructuredSession(
awaitingInput={prompt === null ? null : 'shown'}
turnActivity={controller.turnActivity}
stopping={stopControls.stopping}
onLinkClick={onLinkClick}
allowFileUriLinks={onLinkClick !== undefined}
{...fileLinkProps}
runtimeContext={imageRuntimeContext}
deliveryNotices={deliveryNotices}
/>
@@ -375,8 +377,7 @@ export function NativeChatStructuredSession(
isSubmitting={promptResponse.holds(prompt)}
onCancel={cancelPrompt}
shouldFocus={!promptsUnanswerable && props.isVisible && props.isFocusedGroup}
onLinkClick={onLinkClick}
allowFileUriLinks={onLinkClick !== undefined}
{...fileLinkProps}
/>
) : null}
{prompt && questionBody ? (
@@ -1,3 +1,11 @@
.native-chat-appearance {
--native-chat-find-top-inset: 0px;
}
.native-chat-appearance:has(> .pane-title-bar) {
--native-chat-find-top-inset: var(--orca-pane-title-height);
}
/* Why: find matches are painted as CSS highlights (Ranges, no DOM mutation), in the
markdown preview's colors; see use-native-chat-find-matches.ts.
::highlight() supports color/background only. */
@@ -0,0 +1,382 @@
// @vitest-environment happy-dom
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import type { Tab } from '../../../../shared/tab-types'
import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness'
import { makeTabGroup } from '@/store/slices/store-test-helpers'
import {
makeFolderWorkspace,
makeRepo,
makeUnifiedTab,
makeWorktree
} from '../worktree-jump-palette-test-fixtures'
import * as subjects from '../tab-bar/tab-session-history-switch'
import * as history from '../right-sidebar/ai-vault-panel-session-list-request'
import type { NativeChatCliForkPreparation } from '@/lib/native-chat-cli-fork-preparation'
import type { NativeChatStructuredViewProps } from './native-chat-view-types'
import type * as paneActions from './use-structured-chat-pane-actions'
const work = vi.hoisted(() => ({
header: false,
headerVisits: 0,
otherVisits: 0,
lookups: 0,
commits: 0
}))
const mocks = vi.hoisted(() => ({ prepare: vi.fn(), launch: vi.fn(), close: vi.fn() }))
vi.mock('@/store', async () => {
const { createTestStore } = await import('@/store/slices/store-test-helpers')
return { useAppStore: createTestStore() }
})
vi.mock('./use-structured-chat-pane-actions', async (original) => {
const { useStructuredChatPaneActions } = await original<typeof paneActions>()
return {
useStructuredChatPaneActions(...args: Parameters<typeof useStructuredChatPaneActions>) {
work.header = true
const actions = useStructuredChatPaneActions(...args)
work.header = false
return actions
}
}
})
vi.mock('./NativeChatView', async () => {
const { nativeChatPaneHeader } = await import('./NativeChatPaneHeader')
return {
default: (props: NativeChatStructuredViewProps) => (
<div className="native-chat-appearance" data-chat={props.tabId}>
{nativeChatPaneHeader(props, 'folder:workspace', 'dark')}
<output data-transcript={props.tabId}>{props.sessionId}</output>
</div>
)
}
})
vi.mock('@/lib/workspace-tab-commands', () => ({ dispatchWorkspaceTabCommand: mocks.close }))
vi.mock('@/lib/native-chat-cli-fork-preparation', () => ({
prepareNativeChatCliFork: mocks.prepare
}))
vi.mock('@/lib/launch-ai-vault-session-and-reveal', () => ({
launchAiVaultSessionAndReveal: mocks.launch
}))
vi.mock('@/lib/ai-vault-session-fork-startup', () => ({
assertAiVaultCopiedSessionLaunchAllowed: () => {},
buildAiVaultForkStartupForWorktree: () => ({ command: 'fixture-fork' })
}))
import StructuredAgentSessionPaneOverlayLayer from './StructuredAgentSessionPaneOverlayLayer'
import { NativeChatResumeInCliButton } from './NativeChatResumeInCliButton'
const workspace = 'folder:workspace'
const initial = useAppStore.getInitialState()
const fullRequest = vi.spyOn(history, 'resolveAiVaultPanelSessionListRequest')
const identity = vi.spyOn(subjects, 'resolveNativeChatCliForkIdentity')
const testWindow = window
const originalApi = window.api
const api = createTabsSliceMockApi()
vi.stubGlobal('window', testWindow)
beforeEach(() => {
vi.clearAllMocks()
Object.assign(window, { api })
useAppStore.setState(initial, true)
useAppStore.setState({
folderWorkspaces: [makeFolderWorkspace({ id: 'workspace', folderPath: '/repo' })]
})
})
afterEach(() => {
cleanup()
useAppStore.setState(initial, true)
Object.assign(window, { api: originalApi })
})
function hasPropertyKey<T extends object>(target: T, key: PropertyKey): key is keyof T {
return key in target
}
function countedTab(tab: Tab): Tab {
return new Proxy(tab, {
get(target, key) {
if (key === 'groupId' || key === 'id') {
work[work.header ? 'headerVisits' : 'otherVisits']++
}
return hasPropertyKey(target, key) ? target[key] : undefined
}
})
}
function seedChats(size: number) {
const tabs = Array.from({ length: size }, (_, index) =>
countedTab({
...makeUnifiedTab(`chat-${index}`, workspace, `session-${index}`, `Chat ${index}`),
contentType: 'agent-session',
agentSessionAgent: 'codex',
executionHostId: 'local',
groupId: index === size - 1 ? 'second' : 'first'
})
)
const groups = ['first', 'second'].map((id) =>
makeTabGroup({
id,
worktreeId: workspace,
tabOrder: tabs.filter((tab) => tab.groupId === id).map((tab) => tab.id),
activeTabId: id === 'first' ? 'chat-0' : `chat-${size - 1}`
})
)
useAppStore.setState({
unifiedTabsByWorktree: { [workspace]: tabs },
groupsByWorktree: { [workspace]: groups },
activeGroupIdByWorktree: { [workspace]: 'first' }
})
}
function overlay(active: boolean) {
return (
<TooltipProvider>
<StructuredAgentSessionPaneOverlayLayer worktreeId={workspace} isWorktreeActive={active} />
</TooltipProvider>
)
}
function chat(container: HTMLElement, id: string) {
return within(container).getByText(`session-${id.slice(5)}`).parentElement!
}
it.each([10, 100])(
'bounds shown header tab work at %i retained chats and keeps hidden transcripts',
async (size) => {
seedChats(size)
const view = render(overlay(true))
const retained = chat(view.container, 'chat-0').querySelector('output')
const unfocused = chat(view.container, `chat-${size - 1}`)
expect(within(unfocused).getByRole('button', { name: 'Close Pane' })).toBeTruthy()
for (const operation of [
() => useAppStore.getState().setTabLabel('chat-0', 'Changed title'),
() => useAppStore.getState().activateTab('chat-1', { worktreeId: workspace })
]) {
work.headerVisits = work.otherVisits = 0
await act(async () => operation())
expect(work.headerVisits).toBeGreaterThan(0)
// Filter plus pin lookup: at most four real tab visits per shown header per tab.
expect(work.headerVisits).toBeLessThanOrEqual(4 * 2 * size)
expect(work.otherVisits).toBeGreaterThan(0)
}
expect(useAppStore.getState().getTab('chat-0')?.label).toBe('Changed title')
expect(chat(view.container, 'chat-0').querySelector('output')).toBe(retained)
view.rerender(overlay(false))
work.headerVisits = 0
await act(async () => {
useAppStore.getState().setTabLabel('chat-1', 'Hidden title')
useAppStore.getState().pinTab('chat-1', { recordInteraction: false })
useAppStore.setState({
groupsByWorktree: { [workspace]: [useAppStore.getState().groupsByWorktree[workspace][0]] }
})
})
expect(work.headerVisits).toBe(0)
expect(view.container.querySelector('.pane-title-bar')).toBeNull()
view.rerender(overlay(true))
const shown = chat(view.container, 'chat-1')
expect(within(shown).queryByRole('button', { name: 'Close tab' })).toBeNull()
act(() => useAppStore.getState().unpinTab('chat-1', { recordInteraction: false }))
fireEvent.click(within(shown).getByRole('button', { name: 'Close tab' }))
expect(mocks.close).toHaveBeenCalledExactlyOnceWith({
type: 'close',
target: { kind: 'tab', worktreeId: workspace, tabId: 'chat-1' }
})
expect(chat(view.container, 'chat-0').querySelector('output')).toBe(retained)
expect(fullRequest).not.toHaveBeenCalled()
}
)
function countedRecords<T>(records: T[]): T[] {
return new Proxy(records, {
get(target, key) {
if (typeof key === 'string' && /^\d+$/.test(key)) {
work.lookups++
}
return hasPropertyKey(target, key) ? target[key] : undefined
}
})
}
function eligibility(
folder: string,
git: string,
sessionId = 'session',
agent: subjects.NativeChatTabSession['agent'] = 'codex',
tabId = 'tab'
) {
return (
<TooltipProvider>
<Profiler id="eligibility" onRender={() => work.commits++}>
{[
{ key: 'folder', id: folder },
{ key: 'git', id: git }
].map(({ key, id }) => (
<NativeChatResumeInCliButton
key={key}
tabId={tabId}
worktreeId={id}
nativeChat={{ sessionId, agent }}
/>
))}
</Profiler>
</TooltipProvider>
)
}
it.each([10, 100])(
'does no idle eligibility lookup across %i workspace records and tracks all dependencies',
async (size) => {
const folders = countedRecords(
Array.from({ length: size }, (_, i) =>
makeFolderWorkspace({
id: `folder-${i}`,
folderPath: `/folder/${i}`,
projectGroupId: `project-${size - 1}`
})
)
)
const groups = countedRecords(
Array.from({ length: size }, (_, i) => ({
id: `project-${i}`,
name: `Project ${i}`,
parentPath: null,
parentGroupId: null,
createdFrom: 'manual' as const,
tabOrder: i,
isCollapsed: false,
color: null,
createdAt: 0,
updatedAt: 0
}))
)
const repos = countedRecords(
Array.from({ length: size }, (_, i) => ({
...makeRepo(),
id: `repo-${i}`,
projectGroupId: `project-${size - 1}`
}))
)
const worktrees = Object.fromEntries(
repos.map((repo, i) => [
repo.id,
countedRecords([
makeWorktree(`${repo.id}::/git/${i}`, `Tree ${i}`, { repoId: repo.id, path: `/git/${i}` })
])
])
)
const folder = `folder:folder-${size - 1}`
const git = `repo-${size - 1}::/git/${size - 1}`
useAppStore.setState({
folderWorkspaces: folders,
projectGroups: groups,
repos,
worktreesByRepo: worktrees
})
const view = render(eligibility(folder, git))
work.lookups = work.commits = 0
identity.mockClear()
act(() => {
for (let tick = 0; tick < 50; tick++) {
useAppStore.setState({ lastTerminalInputAtByPaneKey: { 'other:pane': tick } })
}
})
expect(work.lookups).toBe(0)
expect(identity).not.toHaveBeenCalled()
expect(work.commits).toBe(0)
const restore = {
folderWorkspaces: folders,
projectGroups: groups,
repos,
worktreesByRepo: worktrees
}
const replacements: Partial<AppState>[] = [
{
worktreesByRepo: {
...worktrees,
[`repo-${size - 1}`]: countedRecords([
makeWorktree(git, 'missing path', { repoId: `repo-${size - 1}`, path: '' })
])
}
},
{
folderWorkspaces: countedRecords(
folders.map((row, i) => (i === size - 1 ? { ...row, folderPath: '' } : row))
)
},
{
projectGroups: countedRecords(
groups.map((row, i) => (i === size - 1 ? { ...row, executionHostId: 'ssh:dev' } : row))
)
},
{ repos: countedRecords(repos.map((row) => ({ ...row, executionHostId: 'ssh:dev' }))) }
]
for (const replacement of replacements) {
work.lookups = 0
identity.mockClear()
await act(async () => useAppStore.setState(replacement))
expect(identity).toHaveBeenCalledTimes(2)
expect(work.lookups).toBeGreaterThan(0)
// Two shown buttons; existing folder candidates/groups and git lookups are linear in records.
expect(work.lookups).toBeLessThanOrEqual(20 * 2 * size)
const expected = [folder, git].filter((worktreeId) =>
subjects.resolveNativeChatCliForkIdentity(useAppStore.getState(), {
tab: { id: 'tab', worktreeId },
nativeChat: { sessionId: 'session', agent: 'codex' }
})
)
expect(screen.queryAllByRole('button')).toHaveLength(expected.length)
await act(async () => useAppStore.setState(restore))
expect(screen.getAllByRole('button')).toHaveLength(2)
}
identity.mockClear()
view.rerender(eligibility(folder, git))
expect(identity).not.toHaveBeenCalled()
view.rerender(eligibility(folder, git, 'new-session'))
expect(identity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ nativeChat: { sessionId: 'new-session', agent: 'codex' } })
)
identity.mockClear()
view.rerender(eligibility(folder, git, 'new-session', 'codex', 'new-tab'))
expect(identity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ tab: { id: 'new-tab', worktreeId: folder } })
)
view.rerender(eligibility(folder, git, 'new-session', 'gemini', 'new-tab'))
expect(screen.queryAllByRole('button')).toHaveLength(0)
view.rerender(eligibility(folder, git, 'new-session', 'codex', 'new-tab'))
expect(screen.getAllByRole('button')).toHaveLength(2)
view.rerender(eligibility('folder:missing', git, 'new-session', 'codex', 'new-tab'))
expect(screen.getAllByRole('button')).toHaveLength(1)
expect(fullRequest).not.toHaveBeenCalled()
}
)
it('keeps the real Continue pending operation disabled across header hide/reveal and launches once', async () => {
seedChats(10)
const preparation = Promise.withResolvers<NativeChatCliForkPreparation>()
mocks.prepare.mockReturnValue(preparation.promise)
mocks.launch.mockResolvedValue(undefined)
const view = render(overlay(true))
const source = chat(view.container, 'chat-0')
const button = () => within(source).getByRole('button', { name: 'Continue with Terminal Agent' })
expect(fullRequest).not.toHaveBeenCalled()
fireEvent.click(button())
expect(button().hasAttribute('disabled')).toBe(true)
expect(button().getAttribute('aria-busy')).toBe('true')
view.rerender(overlay(false))
expect(within(source).queryByRole('button')).toBeNull()
expect(useAppStore.getState().getTab('chat-0')).not.toBeNull()
view.rerender(overlay(true))
expect(button().hasAttribute('disabled')).toBe(true)
expect(button().getAttribute('aria-busy')).toBe('true')
fireEvent.click(button())
expect(mocks.prepare).toHaveBeenCalledTimes(1)
await act(async () =>
preparation.resolve({
copied: false,
session: { agent: 'codex', sessionId: 'fork', cwd: '/repo', codexHome: null }
})
)
expect(mocks.launch).toHaveBeenCalledTimes(1)
expect(button().hasAttribute('disabled')).toBe(false)
expect(button().hasAttribute('aria-busy')).toBe(false)
expect(fullRequest).toHaveBeenCalledTimes(1)
})
@@ -0,0 +1,29 @@
import { useMemo } from 'react'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { useTabGroupTabCloseCommands } from '../tab-group/useTabGroupTabCloseCommands'
import { runNativeChatSplitTarget } from './native-chat-layout-actions'
import type { Tab, TabGroup } from '../../../../shared/tab-types'
const EMPTY_TABS: Tab[] = []
const EMPTY_GROUPS: TabGroup[] = []
export function useStructuredChatPaneActions(tabId: string, worktreeId: string, groupId: string) {
const tabs = useAppStore((state) => state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_TABS)
const groups = useAppStore((state) => state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS)
const groupTabs = useMemo(() => tabs.filter((tab) => tab.groupId === groupId), [tabs, groupId])
const { closeItem } = useTabGroupTabCloseCommands({ worktreeId })
const closeLabel =
groups.length < 2 || groupTabs.length > 1
? translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab')
: translate('auto.components.terminal.pane.TerminalContextMenu.8c17d6786d', 'Close Pane')
return {
onSplitRight: () =>
runNativeChatSplitTarget({ kind: 'workspace-tab', unifiedTabId: tabId, groupId }, 'right'),
onSplitDown: () =>
runNativeChatSplitTarget({ kind: 'workspace-tab', unifiedTabId: tabId, groupId }, 'down'),
canClosePane: groups.length > 1 || !groupTabs.find((tab) => tab.id === tabId)?.isPinned,
closeLabel,
onClosePane: () => closeItem(tabId)
}
}
@@ -24,7 +24,9 @@ export function NativeChatCliForkMenuItems({
resolveTabSessionHistorySubject(useAppStore.getState(), { tab, nativeChat })
)
const chat = subject?.kind === 'chat' ? subject : null
const pending = useNativeChatCliForkPending(chat)
const pending = useNativeChatCliForkPending(
chat ? { executionHostScope: chat.request.executionHostScope, sessionId: chat.sessionId } : null
)
const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides)
const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent)
if (!chat || !agent) {
@@ -34,10 +34,10 @@ vi.mock('@/store', () => ({
}
}))
vi.mock('./tab-session-history-switch', () => ({
resolveTabSessionHistorySubject: () => ({
kind: 'chat',
request: { executionHostScope: mocks.host }
})
resolveNativeChatCliForkIdentity: () => ({ executionHostScope: mocks.host }),
resolveTabSessionHistorySubject: () => {
throw new Error('History projection in currentness')
}
}))
vi.mock('@/lib/native-chat-cli-fork-preparation', () => ({
prepareNativeChatCliFork: mocks.prepare
@@ -93,6 +93,8 @@ it('shares pending across menu openings and suppresses dispatch after source ret
const first = runNativeChatCliFork(args)
await runNativeChatCliFork(args)
expect(mocks.prepare).toHaveBeenCalledTimes(1)
mocks.listeners.forEach((listener) => listener())
expect(mocks.error).not.toHaveBeenCalled()
closeSource()
settle(prepared)
await first
@@ -23,19 +23,22 @@ import {
onRuntimeEnvironmentRevisionsChanged
} from '@/runtime/runtime-environment-revision'
import type { AiVaultAgent } from '../../../../shared/ai-vault-types'
import { resolveTabSessionHistorySubject } from './tab-session-history-switch'
import {
resolveNativeChatCliForkIdentity,
type NativeChatCliForkIdentity
} from './tab-session-history-switch'
const pending = new Map<string, AbortController>()
const listeners = new Set<() => void>()
const changed = () => listeners.forEach((listener) => listener())
const keyFor = (subject: NativeChatCliForkSubject) =>
`${subject.request.executionHostScope}\0${subject.sessionId}`
type PendingIdentity = Pick<NativeChatCliForkIdentity, 'executionHostScope' | 'sessionId'>
const keyFor = (subject: PendingIdentity) => `${subject.executionHostScope}\0${subject.sessionId}`
const subscribe = (listener: () => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function useNativeChatCliForkPending(subject: NativeChatCliForkSubject | null): boolean {
export function useNativeChatCliForkPending(subject: PendingIdentity | null): boolean {
return useSyncExternalStore(
subscribe,
() => !!subject && pending.has(keyFor(subject)),
@@ -51,7 +54,7 @@ export async function runNativeChatCliFork(args: {
commandOverrides?: Partial<Record<AiVaultAgent, string | null>>
}): Promise<void> {
const { subject, tab } = args
const key = keyFor(subject)
const key = keyFor({ ...subject, executionHostScope: subject.request.executionHostScope })
if (pending.has(key)) {
return
}
@@ -66,15 +69,12 @@ export async function runNativeChatCliFork(args: {
const source = state.unifiedTabsByWorktree[tab.worktreeId]?.find((item) => item.id === tab.id)
const current =
source?.contentType === 'agent-session' && source.entityId === subject.sessionId
? resolveTabSessionHistorySubject(state, {
? resolveNativeChatCliForkIdentity(state, {
tab,
nativeChat: { sessionId: subject.sessionId, agent: args.agent }
})
: null
return (
current?.kind === 'chat' &&
current.request.executionHostScope === subject.request.executionHostScope
)
return current?.executionHostScope === subject.request.executionHostScope
}
if (!isCurrent()) {
return
@@ -24,6 +24,7 @@ import {
findTabSessionHistoryRow,
lookupTabSessionHistoryRow,
resolveTabSessionHistorySubject,
resolveNativeChatCliForkIdentity,
resolveTabSessionSwitch,
type TabSessionHistorySubject
} from './tab-session-history-switch'
@@ -201,6 +202,25 @@ describe('resolveTabSessionHistorySubject', () => {
executionHostId: 'local'
})
})
it.each(['local', 'runtime:server-1', 'ssh:dev-box', null] as const)(
'keeps lightweight native eligibility and the public request on workspace host %s',
(executionHostId) => {
const state = makeState({}, executionHostId ? { executionHostId } : {})
if (!executionHostId) {
state.repos = []
}
const args = {
tab: { id: 'chat-tab', worktreeId: WORKTREE_ID },
nativeChat: { sessionId: 'chat-1', agent: 'claude' as const }
}
const expected = executionHostId === 'ssh:dev-box' ? null : executionHostId
const identity = resolveNativeChatCliForkIdentity(state, args)
const full = resolveTabSessionHistorySubject(state, args)
expect(identity?.executionHostScope ?? null).toBe(expected)
expect(full?.request.executionHostScope ?? null).toBe(expected)
}
)
it('finds a native chat tab by the chat it shows', () => {
expect(
resolveTabSessionHistorySubject(makeState(), {
@@ -113,6 +113,33 @@ export type NativeChatTabSession = {
agent: AgentType | undefined
}
export type NativeChatCliForkIdentity = {
workspaceId: string
sessionId: string
executionHostScope: ExecutionHostScope
}
export function resolveNativeChatCliForkIdentity(
state: AppState,
args: { tab: Pick<TerminalTab, 'id' | 'worktreeId'>; nativeChat: NativeChatTabSession }
): NativeChatCliForkIdentity | null {
const { nativeChat, tab } = args
if (
!nativeChat.agent ||
!agentForksConversations(nativeChat.agent) ||
!resolveAiVaultTargetWorkspacePath(state, tab.worktreeId)
) {
return null
}
const executionHostScope = resolveAiVaultHostScopeDefaults(
getAiVaultResumeWorkspaceExecutionHostId(state, tab.worktreeId),
tab.worktreeId
).defaultExecutionHostScope
return canHostOfferTabSessionMove('chat', executionHostScope)
? { workspaceId: tab.worktreeId, sessionId: nativeChat.sessionId, executionHostScope }
: null
}
export function resolveTabSessionHistorySubject(
state: AppState,
args: {
@@ -134,11 +161,18 @@ export function resolveTabSessionHistorySubject(
}
if (args.nativeChat) {
// A chat tab's only move is opening a copy in its CLI.
const target =
args.nativeChat.agent && agentForksConversations(args.nativeChat.agent)
? workspace(args.tab.worktreeId, 'chat')
: null
return target ? { ...target, kind: 'chat', sessionId: args.nativeChat.sessionId } : null
const identity = resolveNativeChatCliForkIdentity(state, {
...args,
nativeChat: args.nativeChat
})
return identity
? {
kind: 'chat',
workspaceId: identity.workspaceId,
sessionId: identity.sessionId,
request: resolveAiVaultPanelSessionListRequest(state, identity.workspaceId)
}
: null
}
// The pane-to-conversation mapping tab titles use, for the agents a chat can take over, limited to
// agents still running: once the agent exits, the tab title keeps its conversation but the move is
@@ -223,9 +223,8 @@ export default function TerminalPaneHeaderOverlay({
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
variant="pane-title"
size="icon-xs"
className="pane-title-split-trigger"
aria-label={translate(
'components.agentSessionContinuation.handOffToAnotherAgent',
'Hand Off to Another Agent'
@@ -251,10 +250,8 @@ export default function TerminalPaneHeaderOverlay({
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
variant="pane-title"
size="icon-xs"
// Same class as split so it shares the hover/active reveal.
className="pane-title-split-trigger"
aria-label={translate(
'components.native-chat.toggle.showTerminal',
'Show terminal'
@@ -283,9 +280,8 @@ export default function TerminalPaneHeaderOverlay({
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
variant="pane-title"
size="icon-xs"
className="pane-title-split-trigger"
data-contextual-tour-target={
isActivePane ? 'terminal-pane-split-target' : undefined
}
@@ -308,9 +304,8 @@ export default function TerminalPaneHeaderOverlay({
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
variant="pane-title-close"
size="icon-xs"
className="pane-title-close"
onClick={(event) => {
event.stopPropagation()
onRemoveTitle(pane.id)
@@ -337,9 +332,8 @@ export default function TerminalPaneHeaderOverlay({
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
variant="pane-title-close"
size="icon-xs"
className="pane-title-close"
onClick={(event) => {
event.stopPropagation()
onClosePane(pane.id)
@@ -35,10 +35,8 @@ export function TerminalPaneResumeInChatButton({
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
variant="pane-title"
size="icon-xs"
// Same class as split so it shares the hover/active reveal.
className="pane-title-split-trigger"
aria-label={label}
disabled={!source || pending}
aria-busy={pending || undefined}
@@ -17,6 +17,8 @@ const buttonVariants = cva(
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
'pane-title': 'pane-title-split-trigger',
'pane-title-close': 'pane-title-close',
// The worktree card's "rename failed" chip, for a failure the user can open.
'destructive-chip':
'border border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/15 hover:text-destructive'
@@ -0,0 +1,6 @@
@import '../../../src/renderer/src/assets/main.css';
@source './fixture.tsx';
@source '../../../src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx';
@source '../../../src/renderer/src/components/native-chat/NativeChatFindBar.tsx';
@source '../../../src/renderer/src/components/native-chat/NativeChatContextClearFade.tsx';
@source '../../../src/renderer/src/components/ui';
@@ -0,0 +1,44 @@
import React, { useRef } from 'react'
import { createRoot } from 'react-dom/client'
import { NativeChatPaneHeader } from '@/components/native-chat/NativeChatPaneHeader'
import { NativeChatFindBar } from '@/components/native-chat/NativeChatFindBar'
import { NativeChatContextClearFade } from '@/components/native-chat/NativeChatContextClearFade'
import { nativeChatAppearanceStyle } from '@/components/native-chat/native-chat-appearance-style'
import { useNativeChatFind } from '@/components/native-chat/use-native-chat-find'
import { TooltipProvider } from '@/components/ui/tooltip'
import './fixture.css'
function Fixture() {
const root = useRef<HTMLDivElement>(null)
const find = useNativeChatFind(false, root, useRef(null), useRef(null))
return (
<TooltipProvider>
<button onClick={() => find.open?.()}>Open Find</button>
<div
ref={root}
className="native-chat-appearance relative m-6 flex h-96 w-[480px] flex-col"
style={nativeChatAppearanceStyle({ theme: 'dark' })}
>
<NativeChatPaneHeader
tabId="fixture"
worktreeId="fixture"
groupId="fixture"
nativeChat={{ sessionId: 'fixture', agent: undefined }}
focused
colorScheme="dark"
/>
<NativeChatContextClearFade clearing={false}>
{find.isOpen && <NativeChatFindBar find={find} isVisible />}
<p data-native-chat-transcript-column className="pt-20">
alpha alpha
</p>
<textarea aria-label="Composer" />
</NativeChatContextClearFade>
</div>
</TooltipProvider>
)
}
const root = document.getElementById('root')
if (root) {
createRoot(root).render(<Fixture />)
}
@@ -0,0 +1,10 @@
<!doctype html>
<html class="dark">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./fixture.tsx"></script>
</body>
</html>
@@ -0,0 +1,120 @@
import { _electron as electron, expect } from '@stablyai/playwright-test'
import { build as buildMain } from 'esbuild'
import { build as buildRenderer } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
import { userInfo } from 'node:os'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
expect(process.env.ORCA_BACKGROUND_LAUNCH).toBe('1')
expect(process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL).toBe('1')
const home = realpathSync(process.env.HOME)
const realHome = userInfo().homedir
expect(realHome === home || realHome.startsWith(home + path.sep)).toBe(false)
for (const name of [
'USERPROFILE',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'XDG_STATE_HOME',
'XDG_CACHE_HOME',
'XDG_RUNTIME_DIR',
'APPDATA',
'LOCALAPPDATA',
'CLAUDE_CONFIG_DIR',
'CODEX_HOME',
'ORCA_E2E_USER_DATA_DIR',
'ORCA_E2E_HOME_DIR'
]) {
const directory = realpathSync(process.env[name])
expect(directory === home || directory.startsWith(home + path.sep), name).toBe(true)
}
const root = fileURLToPath(new URL('../../../', import.meta.url))
const parent = path.join(root, '.bench-fixtures')
mkdirSync(parent, { recursive: true })
const reuse = process.argv[2]
const output = reuse ? realpathSync(reuse) : mkdtempSync(path.join(parent, 'native-chat-controls-'))
expect(path.dirname(output)).toBe(realpathSync(parent))
const main = path.join(output, 'main.cjs')
if (!reuse) {
await buildMain({
entryPoints: [path.join(root, 'tests/tools/benchmarks/spinner-rendering/main.ts')],
outfile: main,
bundle: true,
platform: 'node',
format: 'cjs',
external: ['electron']
})
await buildRenderer({
configFile: false,
root: import.meta.dirname,
base: './',
logLevel: 'silent',
plugins: [react(), tailwindcss()],
resolve: { alias: { '@': path.join(root, 'src/renderer/src') } },
build: { outDir: path.join(output, 'renderer'), emptyOutDir: true }
})
}
const { ELECTRON_RUN_AS_NODE: _node, ...env } = process.env
const app = await electron.launch({
args: [main, '--password-store=basic', '--use-mock-keychain'],
env
})
try {
const page = await app.firstWindow()
const errors = []
page.on('pageerror', (error) => errors.push(error.message))
const url = pathToFileURL(path.join(output, 'renderer/index.html')).href
const search = page.getByRole('search')
const next = page.getByRole('button', { name: 'Next match', exact: true })
async function openFind() {
await page.goto(url)
await page.getByRole('button', { name: 'Open Find', exact: true }).click()
await page.getByRole('textbox', { name: 'Find in chat', exact: true }).fill('alpha')
await expect(search).toContainText('1/2')
}
// Regression 1: occupied header clears Find and its ordinary Next action works.
await openFind()
const header = await page.locator('.pane-title-bar').boundingBox()
const bar = await search.boundingBox()
expect(Math.min(header.width, header.height, bar.width, bar.height)).toBeGreaterThan(0)
expect(bar.y).toBeGreaterThan(header.y + header.height)
await next.click()
await expect(search).toContainText('2/2')
// Regression 2: a keyboard-held label overlaps Next, but the actual click reaches Next.
await openFind()
await page.getByRole('textbox', { name: 'Find in chat', exact: true }).press('Shift+Tab')
await page.keyboard.press('Shift+Tab')
await expect(page.getByRole('button', { name: 'Split Chat Right', exact: true })).toBeFocused()
const label = page.locator('[data-slot="tooltip-content"][data-state="instant-open"]')
await expect(label).toBeVisible()
const probe = await label.evaluate((content) => {
const button = document.querySelector('button[aria-label="Next match"]')
const wrapper = content.closest('[data-radix-popper-content-wrapper]')
const b = button.getBoundingClientRect(),
c = content.getBoundingClientRect(),
w = wrapper.getBoundingClientRect()
const x = b.x + b.width / 2,
y = b.y + b.height / 2
const covers = (r) =>
r.width > 0 && r.height > 0 && x > r.left && x < r.right && y > r.top && y < r.bottom
return {
overlap: covers(c) && covers(w),
content: getComputedStyle(content).pointerEvents,
wrapper: getComputedStyle(wrapper).pointerEvents,
hit: button.contains(document.elementFromPoint(x, y))
}
})
expect(probe).toEqual({ overlap: true, content: 'none', wrapper: 'none', hit: true })
await next.click()
await expect(search).toContainText('2/2')
expect(errors).toEqual([])
const windows = await app.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows().map((w) => [w.isVisible(), w.isFocused()])
)
expect(windows).toEqual([[false, false]])
console.log(`PASS: native header geometry and tooltip pointer passage (${output})`)
} finally {
await app.close()
}