mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
co-authored by
Orca Worker
parent
6e3f205749
commit
d49ad60ead
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user