From b6d760d4f9e77efa8f10c23dee2c78557a504084 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 4 Nov 2026 20:37:28 -0800 Subject: [PATCH] test: remove low-value dashboard and preview tests (#38362) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/flaky-test-pattern-baseline.txt | 1 - config/scripts/ci-shard-timings.json | 2 - .../dashboard-popout/AgentKanbanCard.test.tsx | 366 ----------- .../AgentTerminalDialog.test.tsx | 124 ---- ...tTerminalPreview.clipboard-routes.test.tsx | 240 ------- ...TerminalPreview.settings-geometry.test.tsx | 68 -- ...TerminalPreview.settings-lifetime.test.tsx | 118 +--- .../AgentTerminalPreview.test.tsx | 408 ------------ ...ashboard-preview-link-composition.test.tsx | 360 +--------- ...rd-preview-popout-default-test-fixture.tsx | 135 ---- ...ew-terminal-ime-bridge-kitty-bytes.test.ts | 26 - .../useDashboardSnapshot.test.tsx | 204 +----- .../dashboard/DashboardAgentRow.test.tsx | 614 ------------------ 13 files changed, 7 insertions(+), 2659 deletions(-) delete mode 100644 src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/dashboard-preview-popout-default-test-fixture.tsx delete mode 100644 src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 60a2d52bd71..12aa2ccee52 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -289,7 +289,6 @@ 1 test-no-real-time-wait:src/main/wsl/orcad/wsl-orcad-stdio-bridge.test.ts 1 test-no-real-time-wait:src/renderer/src/components/UpdateCard.error-card.test.tsx 1 test-no-real-time-wait:src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx -1 test-no-real-time-wait:src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx 1 test-no-real-time-wait:src/renderer/src/components/editor/DiagramViewerModal.test.tsx 1 test-no-real-time-wait:src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-stale-analysis-work-budget.test.tsx 1 test-no-real-time-wait:src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 08f5cf2d50b..902cdb31d69 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -6898,7 +6898,6 @@ "src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts": 208, "src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx": 425, "src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx": 1004, - "src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx": 841, "src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx": 935, "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx": 2983, "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx": 2678, @@ -6917,7 +6916,6 @@ "src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx": 432, "src/renderer/src/components/dashboard/AgentDashboardDrawer.focus.test.tsx": 2567, "src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx": 1981, - "src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx": 2350, "src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts": 48, "src/renderer/src/components/dashboard/agent-destination-navigation.integration.test.tsx": 3232, "src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts": 107, diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx deleted file mode 100644 index 6dac14aa918..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ /dev/null @@ -1,366 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' -import type { RepoIcon } from '../../../../shared/repo-icon' -import { i18n } from '@/i18n/i18n' -import { TooltipProvider } from '@/components/ui/tooltip' -import { AgentKanbanCard } from './AgentKanbanCard' - -const agentIconRender = vi.fn() -const agentStateDotRender = vi.fn() - -vi.mock('@/lib/agent-catalog', () => ({ - AgentIcon: () => { - agentIconRender() - return - } -})) - -vi.mock('@/components/AgentStateDot', () => ({ - AgentStateDot: ({ state }: { state: string }) => { - agentStateDotRender(state) - return - } -})) - -function card(overrides: Partial = {}): DashboardCard { - return { - paneKey: 'tab:leaf', - ptyId: 'pty-1', - agentType: 'claude', - bucket: 'working', - dotState: 'working', - task: 'Review the change', - repoId: 'repo-1', - worktreeId: 'worktree-1', - tabId: 'tab', - leafId: 'leaf', - repoName: 'Orca', - worktreeName: 'dashboard-review', - startedAt: 1_000, - finishedAt: null, - stateChangedAt: 1_000, - unseen: false, - ...overrides - } -} - -function renderCard(props: { - card: DashboardCard - now: number - repoIcon?: RepoIcon | null - onOpenTerminal?: () => void -}): ReturnType { - return render( - - - - ) -} - -describe('AgentKanbanCard', () => { - beforeEach(async () => { - await i18n.changeLanguage('en') - }) - - afterEach(() => { - cleanup() - vi.clearAllMocks() - }) - - it('rerenders on a verdict-only change without a clean completion tint', () => { - const initial = card({ dotState: 'done', bucket: 'idle' }) - const onOpenTerminal = vi.fn() - const { container, rerender } = renderCard({ card: initial, now: 2000, onOpenTerminal }) - for (const verdict of ['interrupted', 'failed', 'unconfirmed'] as const) { - rerender( - - - - ) - expect(agentStateDotRender).toHaveBeenLastCalledWith(verdict) - expect(container.firstElementChild?.className).not.toContain('emerald') - } - }) - - it('does not render an invented age when the start time is unknown', () => { - renderCard({ card: card({ startedAt: 0 }), now: 2_000_000_000 }) - - expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument() - }) - - it('shows the question glyph once when a summary is available', () => { - const attentionCard = card({ - bucket: 'attention', - dotState: 'waiting', - askSummary: 'Approve deploy?', - verdict: 'failed' - }) - const { container, rerender } = renderCard({ card: attentionCard, now: 2_000 }) - - expect(screen.queryByTestId('state-dot')).not.toBeInTheDocument() - expect(container.querySelectorAll('.lucide-message-circle-question-mark')).toHaveLength(1) - - rerender( - - - - ) - expect(screen.getByTestId('state-dot')).toBeInTheDocument() - expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull() - }) - - it('shows the saved SSH host beside the repository metadata', () => { - const { container } = renderCard({ - card: card({ - hostKind: 'ssh', - executionHostId: 'ssh:opaque-target', - hostLabel: 'openclaw' - }), - now: 2_000 - }) - - expect(screen.getByLabelText('SSH host · openclaw')).toHaveAttribute( - 'data-dashboard-host-badge', - 'ssh' - ) - expect(container.querySelector('.lucide-server')).toBeInTheDocument() - }) - - it('shows review metadata and expands grouped subagents without opening the terminal', () => { - const onOpenTerminal = vi.fn() - renderCard({ - card: card({ - review: { number: 11012, state: 'open' }, - subagents: [ - { id: 'child-1', name: 'Review loop', dotState: 'working' }, - { id: 'child-2', name: 'Smoke tests', dotState: 'done' } - ] - }), - now: 2_000, - onOpenTerminal - }) - - expect(screen.getByText('#11012')).toBeInTheDocument() - expect(screen.getByRole('img', { name: 'Open review #11012' })).toBeInTheDocument() - expect(screen.queryByText('Review loop')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '2 subagents' })) - expect(screen.getByText('Review loop')).toBeInTheDocument() - expect(screen.getByText('Smoke tests')).toBeInTheDocument() - expect(onOpenTerminal).not.toHaveBeenCalled() - }) - - it('opens the terminal from the footer while keeping subagent disclosure isolated', () => { - const onOpenTerminal = vi.fn() - renderCard({ - card: card({ - conversationName: 'Dashboard review', - review: { number: 11042, state: 'open' }, - subagents: [{ id: 'child-1', name: 'Review loop', dotState: 'working' }] - }), - now: 61_000, - onOpenTerminal - }) - - fireEvent.click(screen.getByText('#11042')) - expect(onOpenTerminal).toHaveBeenCalledTimes(1) - - fireEvent.click(screen.getByRole('button', { name: '1 subagent' })) - expect(onOpenTerminal).toHaveBeenCalledTimes(1) - }) - - it('labels one subagent accessibly and never renders a workspace-status dot', () => { - renderCard({ - card: card({ - workspaceStatusId: 'in-review', - workspaceStatusLabel: 'In review', - workspaceStatusColor: 'emerald', - subagents: [{ id: 'child-1', name: 'Review loop', dotState: 'working' }] - }), - now: 2_000 - }) - - expect(screen.getByRole('button', { name: '1 subagent' })).toBeInTheDocument() - expect(screen.queryByRole('img', { name: 'In review' })).not.toBeInTheDocument() - }) - - it('tints unseen Done green and keeps acknowledged Done neutral as Idle', () => { - const { container: attention } = renderCard({ - card: card({ bucket: 'attention', dotState: 'waiting' }), - now: 2_000 - }) - expect(attention.firstElementChild?.className).toContain('border-agent-question/40') - - cleanup() - const { container: done } = renderCard({ - card: card({ bucket: 'done', dotState: 'done', unseen: true }), - now: 2_000 - }) - expect(done.firstElementChild?.className).toContain('border-emerald-500/40') - - cleanup() - const { container: idle } = renderCard({ - card: card({ bucket: 'idle', dotState: 'done', unseen: false }), - now: 2_000 - }) - const idleClassName = idle.firstElementChild?.className ?? '' - expect(idleClassName).toContain('border-border/60') - expect(idleClassName).not.toContain('emerald') - expect(idleClassName).not.toContain('agent-question') - }) - - it('heads the card with the conversation name and drops the worktree to the footer', () => { - const { container } = renderCard({ - card: card({ lastUserMessage: 'ship it', conversationName: 'Sparse-checkout parser' }), - now: 2_000 - }) - - const cardElement = container.firstElementChild! - const header = cardElement.querySelector('button')!.firstElementChild! - const footer = cardElement.lastElementChild! - expect(header).toHaveTextContent('Sparse-checkout parser') - expect(header).not.toHaveTextContent('dashboard-review') - expect(footer).toHaveTextContent('dashboard-review') - expect(screen.getByText('You')).toBeInTheDocument() - }) - - it('heads the card with the worktree when no name resolves, without repeating it', () => { - const { container } = renderCard({ card: card({ lastUserMessage: 'ship it' }), now: 2_000 }) - - expect(screen.getAllByText('dashboard-review')).toHaveLength(1) - expect(container.querySelector('button')!.firstElementChild).toHaveTextContent( - 'dashboard-review' - ) - }) - - it('shows the repo as an icon labelled with its name instead of inline text', () => { - renderCard({ - card: card(), - now: 2_000, - repoIcon: { type: 'emoji', emoji: '🐳' } - }) - - expect(screen.getByLabelText('Orca')).toBeInTheDocument() - expect(screen.getByText('🐳')).toBeInTheDocument() - }) - - it('skips structured-clone rerenders until visible card data or its age changes', () => { - const onOpenTerminal = vi.fn() - const initial = card({ - startedAt: 1_000, - subagents: [{ id: 'child-1', name: 'Review loop', dotState: 'working' }] - }) - const repoIcon: RepoIcon = { type: 'lucide', name: 'Rocket' } - const { rerender } = render( - - - - ) - expect(agentIconRender).toHaveBeenCalledTimes(1) - expect(screen.getByText('1m')).toBeInTheDocument() - - // A fresh structured clone of identical data — including the repo icon. - rerender( - - ({ ...subagent })) }} - repoIcon={{ ...repoIcon }} - now={62_000} - onOpenTerminal={onOpenTerminal} - /> - - ) - expect(agentIconRender).toHaveBeenCalledTimes(1) - - rerender( - - ({ ...subagent })) }} - repoIcon={{ ...repoIcon }} - now={121_500} - onOpenTerminal={onOpenTerminal} - /> - - ) - expect(agentIconRender).toHaveBeenCalledTimes(2) - expect(screen.getByText('2m')).toBeInTheDocument() - }) - - it('rerenders when a working card enters monitoring', () => { - const initial = card() - const { rerender } = renderCard({ card: initial, now: 2_000 }) - expect(agentStateDotRender).toHaveBeenLastCalledWith('working') - - rerender( - - - - ) - - expect(agentStateDotRender).toHaveBeenLastCalledWith('monitoring') - }) - - it('rerenders when the repo icon changes', () => { - const onOpenTerminal = vi.fn() - const initial = card({ startedAt: 1_000 }) - const { rerender } = render( - - - - ) - expect(agentIconRender).toHaveBeenCalledTimes(1) - - rerender( - - - - ) - expect(agentIconRender).toHaveBeenCalledTimes(2) - }) - - it('updates the relative age when the UI language changes', async () => { - renderCard({ card: card({ startedAt: 1_000 }), now: 121_500 }) - expect(screen.getByText('2m')).toBeInTheDocument() - - await act(async () => { - await i18n.changeLanguage('ja') - }) - - expect(screen.getByText('2分')).toBeInTheDocument() - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index 5b9d021828c..bffc3de2dd0 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -37,15 +37,6 @@ vi.mock('./AgentTerminalPreview', () => ({ ) })) -const TERMINAL_INPUT: DashboardCardTerminalInput = { - hostPlatform: 'win32', - localWindowsConpty: true, - osRelease: '10.0.22631', - windowsShiftEnterEncoding: 'csi-u', - ctrlEnterCsiU: false, - kittyKeyboardAdvertised: false -} - function card(overrides: Partial = {}): DashboardCard { return { paneKey: 'tab1:leaf1', @@ -74,121 +65,6 @@ afterEach(() => { // This prop is the only path carrying relayed host-input facts into the preview; omission silently falls back to client-OS routing. describe('AgentTerminalDialog', () => { - it("hands the card's relayed host-input profile to the preview terminal", () => { - render( - {}} - onReveal={() => {}} - /> - ) - - expect(screen.getByTestId('preview')).toHaveAttribute( - 'data-terminal-input', - JSON.stringify(TERMINAL_INPUT) - ) - }) - - it.each([ - ['interrupted', 'Interrupted'], - ['failed', 'Failed'], - ['unconfirmed', 'Couldn’t confirm'] - ] as const)('keeps an acknowledged %s verdict in the terminal header', (verdict, label) => { - const statusCard = { ...card({ dotState: 'done', bucket: 'idle', unseen: false }), verdict } - render( {}} onReveal={() => {}} />) - expect(screen.getByText(new RegExp(label))).toBeInTheDocument() - }) - - it('passes null when the card carries no profile, so the preview routes by client OS', () => { - render( {}} onReveal={() => {}} />) - - expect(screen.getByTestId('preview')).toHaveAttribute('data-terminal-input', 'null') - }) - - it('does not claim a remote pane closed when the card carries no live pty', () => { - render( - {}} - onReveal={() => {}} - /> - ) - - // Loss of contact with an SSH host is `unverifiable`, never `exited`. - expect(screen.getByText(/remote session/)).toBeInTheDocument() - expect(screen.queryByText(/pane has closed/)).not.toBeInTheDocument() - }) - - it('keeps a local agent openable without claiming its missing preview means closure', () => { - render( - {}} - onReveal={() => {}} - /> - ) - - expect(screen.getByText(/No preview available/)).toBeInTheDocument() - expect(screen.queryByText(/pane has closed/)).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Open worktree' })).toBeEnabled() - }) - - it('labels acknowledged completions idle without review or pin controls', () => { - render( - {}} - onReveal={() => {}} - /> - ) - - expect(screen.getByText(/Claude · Idle/)).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Keep visible' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Mark reviewed' })).not.toBeInTheDocument() - expect(screen.getByTestId('preview')).toHaveAttribute('data-pty-id', 'pty-1') - }) - - it('labels unseen completions done', () => { - render( - {}} - onReveal={() => {}} - /> - ) - - expect(screen.getByText(/Claude · Done/)).toBeInTheDocument() - }) - - it('preserves the execution host when revealing a colliding worktree ID', () => { - const onReveal = vi.fn() - render( - {}} - onReveal={onReveal} - /> - ) - - fireEvent.click(screen.getByRole('button', { name: 'Open worktree' })) - expect(onReveal).toHaveBeenCalledWith({ - repoId: 'r1', - worktreeId: 'w1', - executionHostId: 'runtime:env-1', - tabId: 'tab1', - leafId: 'leaf1' - }) - }) - - it('reuses the terminal surface as a non-modal adjacent panel', () => { - render( {}} onReveal={() => {}} />) - - expect(screen.getByRole('dialog', { name: 'wt' })).toHaveAttribute('data-state', 'open') - expect(screen.getByTestId('preview')).toHaveClass('min-h-0', 'flex-1') - expect(document.querySelector('[data-slot="dialog-overlay"]')).not.toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'wt' })).toBeInTheDocument() - }) - it('lets a nested Radix layer consume Escape before closing the panel', () => { const onOpenChange = vi.fn() render( diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx index c96f069df24..d763c1fc533 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx @@ -14,7 +14,6 @@ import { BRACKETED_PASTE_START } from '../../../../shared/terminal/input/terminal-bracketed-paste-text' import { dispatchAppMenuPasteEvent } from '@/lib/app-menu-paste' -import { dispatchAppMenuSelectionAction } from '@/lib/app-menu-selection-actions' const terminalHarness = vi.hoisted(() => ({ instances: [] as { @@ -216,245 +215,6 @@ describe('AgentTerminalPreview clipboard routes', () => { vi.clearAllMocks() }) - it('claims the app-menu paste event and pastes while the preview owns focus', async () => { - const view = render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - focusInsidePreview(view.container) - - let claimed = false - act(() => { - claimed = dispatchAppMenuPasteEvent() - }) - - // The claim is what keeps App-level paste out of xterm's hidden textarea. - expect(claimed).toBe(true) - await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text')) - expect(input).toHaveBeenCalledWith('pty-1', 'clip-text') - }) - - it('encodes a leading newline for a remote Windows Codex preview without submitting', async () => { - readClipboardText.mockResolvedValueOnce('\nsecond line') - const view = render( - - ) - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - focusInsidePreview(view.container) - - act(() => { - dispatchAppMenuPasteEvent() - }) - - await waitFor(() => - expect(input).toHaveBeenCalledWith('remote:windows-box@@pty-1', '\x1b\rsecond line') - ) - expect(terminal.input).toHaveBeenCalledWith('\x1b\rsecond line') - expect(terminal.paste).not.toHaveBeenCalled() - }) - - it('claims app-menu selection actions while the preview owns focus', async () => { - const view = render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - terminal.selectionText = 'selected text' - const focusTarget = focusInsidePreview(view.container, 'textarea') - focusTarget.className = 'xterm-helper-textarea' - - let selectAllClaimed = false - let copyClaimed = false - act(() => { - selectAllClaimed = dispatchAppMenuSelectionAction('select-all') - }) - act(() => { - copyClaimed = dispatchAppMenuSelectionAction('copy') - }) - - expect(selectAllClaimed).toBe(true) - expect(copyClaimed).toBe(true) - expect(terminal.selectAll).toHaveBeenCalledOnce() - await waitFor(() => expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text')) - }) - - it('leaves app-menu selection actions unclaimed for text controls inside the preview', async () => { - const view = render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - focusInsidePreview(view.container) - - let claimed = true - act(() => { - claimed = dispatchAppMenuSelectionAction('select-all') - }) - - // Unclaimed so the App-level handler performs the native selection action. - expect(claimed).toBe(false) - expect(terminal.selectAll).not.toHaveBeenCalled() - }) - - it('leaves a copy action with no terminal selection unclaimed', async () => { - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - - let claimed = true - act(() => { - claimed = dispatchAppMenuSelectionAction('copy') - }) - - expect(claimed).toBe(false) - expect(writeTerminalClipboardText).not.toHaveBeenCalled() - }) - - it('leaves the app-menu paste event unclaimed when focus is outside the preview', async () => { - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - - let claimed = true - await act(async () => { - claimed = dispatchAppMenuPasteEvent() - }) - - expect(claimed).toBe(false) - expect(readClipboardText).not.toHaveBeenCalled() - expect(terminalHarness.instances[0]!.paste).not.toHaveBeenCalled() - }) - - it('stops claiming app-menu clipboard events once unmounted', async () => { - const view = render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - focusInsidePreview(view.container) - view.unmount() - - expect(dispatchAppMenuPasteEvent()).toBe(false) - expect(dispatchAppMenuSelectionAction('select-all')).toBe(false) - }) - - it('pastes on plain Ctrl+V on Windows, where no Edit-menu accelerator ever fires', async () => { - platformState.value = 'win32' - const view = render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - focusInsidePreview(view.container) - - const plain = new KeyboardEvent('keydown', { - key: 'v', - code: 'KeyV', - ctrlKey: true, - cancelable: true - }) - expect(terminal.customKeyHandler!(plain)).toBe(false) - expect(plain.defaultPrevented).toBe(true) - await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text')) - - // The repeat is swallowed without a second clipboard read, and so is the keyup. - expect( - terminal.customKeyHandler!( - new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', ctrlKey: true, repeat: true }) - ) - ).toBe(false) - expect(terminal.customKeyHandler!(new KeyboardEvent('keyup', { key: 'v', code: 'KeyV' }))).toBe( - false - ) - expect(readClipboardText).toHaveBeenCalledTimes(1) - }) - - it('handles the shifted paste chord on Linux', async () => { - const view = render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - focusInsidePreview(view.container) - - const shifted = new KeyboardEvent('keydown', { - key: 'V', - code: 'KeyV', - ctrlKey: true, - shiftKey: true, - cancelable: true - }) - expect(terminal.customKeyHandler!(shifted)).toBe(false) - expect(shifted.defaultPrevented).toBe(true) - await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text')) - expect(readClipboardText).toHaveBeenCalledTimes(1) - }) - - it('leaves plain Cmd+V to the macOS Edit-menu accelerator', async () => { - platformState.value = 'darwin' - const view = render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - focusInsidePreview(view.container) - - // macOS Cmd+V uses its native accelerator; handling it here too would paste twice. - const plain = terminal.customKeyHandler!( - new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', metaKey: true }) - ) - expect(plain).toBe(true) - expect(readClipboardText).not.toHaveBeenCalled() - }) - - it('pastes on terminal-style right-click when the setting is on', async () => { - storeState.settings = { terminalRightClickToPaste: true } - const view = render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - focusInsidePreview(view.container) - const host = view.container.querySelector('.origin-bottom-left')! - - const menuEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }) - act(() => { - host.dispatchEvent(menuEvent) - }) - - expect(menuEvent.defaultPrevented).toBe(true) - await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text')) - }) - - it('copies the selection on terminal-style right-click instead of pasting', async () => { - storeState.settings = { terminalRightClickToPaste: true } - const view = render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - terminal.selectionText = 'selected text' - focusInsidePreview(view.container) - const host = view.container.querySelector('.origin-bottom-left')! - - act(() => { - host.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })) - }) - - await waitFor(() => expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text')) - expect(readClipboardText).not.toHaveBeenCalled() - }) - - it('leaves right-click to the native menu when the setting is off', async () => { - storeState.settings = { terminalRightClickToPaste: false } - const view = render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - focusInsidePreview(view.container) - const host = view.container.querySelector('.origin-bottom-left')! - - const menuEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }) - act(() => { - host.dispatchEvent(menuEvent) - }) - - expect(menuEvent.defaultPrevented).toBe(false) - expect(readClipboardText).not.toHaveBeenCalled() - }) - it('cancels an async paste when the preview loses focus', async () => { let resolveClipboard!: (text: string) => void readClipboardText.mockReturnValueOnce( diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx index 02a39386e39..81077749fe7 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx @@ -158,74 +158,6 @@ async function settleGeometry(): Promise { }) } -it.each([ - { - updates: { terminalFontSize: 18 }, - cols: 50, - rows: 15, - scale: 'scale(0.625)', - anchor: 'top left' - }, - { - updates: { terminalFontFamily: 'Fira Code' }, - cols: 50, - rows: 15, - scale: 'scale(0.625)', - anchor: 'top left' - }, - { - updates: { terminalFontWeight: 900 }, - cols: 50, - rows: 15, - scale: 'scale(0.625)', - anchor: 'top left' - }, - { - updates: { terminalFontWeightBold: 900 }, - cols: 50, - rows: 15, - scale: 'scale(0.625)', - anchor: 'top left' - }, - { - updates: { terminalLineHeight: 1.5 }, - cols: 60, - rows: 10, - scale: 'scale(0.75)', - anchor: 'bottom left' - }, - { - updates: { terminalLigatures: 'on' }, - cols: 60, - rows: 15, - scale: 'scale(0.75)', - anchor: 'top left' - } -] satisfies { - updates: Partial - cols: number - rows: number - scale: string - anchor: string -}[])( - 'recreates the owner and fits the new geometry for $updates', - async ({ updates, cols, rows, scale, anchor }) => { - render() - await settleGeometry() - expect(fit).toHaveBeenLastCalledWith('pty-1', 60, 15) - await act(async () => useAppStore.getState().updateSettings(updates)) - await settleGeometry() - - expect(fit).toHaveBeenCalledTimes(2) - expect(fit).toHaveBeenLastCalledWith('pty-1', cols, rows) - expect(harness.instances).toHaveLength(2) - expect(connect).toHaveBeenCalledTimes(2) - expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(harness.instances[1]?.container?.style.transform).toBe(scale) - expect(harness.instances[1]?.container?.style.transformOrigin).toBe(anchor) - } -) - it('ignores a pending connection retired by a metric change', async () => { const gate = Promise.withResolvers() connect.mockReturnValueOnce(gate.promise) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx index 35f2669f083..0a1f31b7705 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx @@ -6,7 +6,7 @@ import type { ITerminalOptions } from '@xterm/xterm' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TerminalPreviewConnectResult } from '../../../../shared/terminal/replay/terminal-preview' import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' -import { makeCustomTerminalThemeSelection } from '../../../../shared/terminal-themes/terminal-custom-themes' + import { useAppStore } from '@/store' import { AgentTerminalPreview } from './AgentTerminalPreview' @@ -79,17 +79,13 @@ const initial = useAppStore.getInitialState() const connect = vi.fn() const unsubscribe = vi.fn(async () => {}) const offData = vi.fn() -const onData = vi.fn((listener: Parameters[0]) => { - emitData = listener - return offData -}) +const onData = vi.fn(() => offData) const connection: TerminalPreviewConnectResult = { snapshot: { data: 'agent prompt', cols: 80, rows: 24, seq: 1 }, replay: [] } let settings: GlobalSettings let originalApi: PropertyDescriptor | undefined -let emitData: Parameters[0] | undefined async function updateSettings(updates: Partial): Promise { await act(async () => useAppStore.getState().updateSettings(updates)) @@ -107,7 +103,6 @@ beforeEach(() => { vi.clearAllMocks() harness.instances.length = 0 harness.systemPrefersDark = false - emitData = undefined settings = createGlobalSettingsFixture({ theme: 'dark', activeRuntimeEnvironmentId: null }) connect.mockReset().mockResolvedValue(connection) originalApi = Object.getOwnPropertyDescriptor(window, 'api') @@ -158,80 +153,6 @@ describe('preview terminal settings lifetime', () => { expect(unsubscribe).not.toHaveBeenCalled() }) - it('updates cursor options on the existing terminal', async () => { - render() - await act(async () => {}) - await updateSettings({ - terminalCursorStyle: 'bar', - terminalCursorBlink: false - }) - expect(connect).toHaveBeenCalledOnce() - expect(terminal().options).toMatchObject({ - cursorStyle: 'bar', - cursorBlink: false - }) - }) - - it.each([ - { terminalThemeDark: 'Builtin Tango Light' }, - { terminalColorOverrides: { background: '#123456' } }, - { terminalBackgroundOpacity: 0.5 }, - { terminalCursorOpacity: 0.5 }, - { terminalMinimumContrastRatio: 7 } - ] satisfies Partial[])( - 'replaces the terminal when theme or contrast changes: %o', - async (updates) => { - render() - await act(async () => {}) - await updateSettings(updates) - expect(connect).toHaveBeenCalledTimes(2) - expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(terminal().dispose).toHaveBeenCalledOnce() - expect(harness.instances).toHaveLength(2) - if ('terminalMinimumContrastRatio' in updates) { - expect(terminal(1).options.minimumContrastRatio).toBe(7) - } else { - expect(terminal(1).options.theme).not.toEqual(terminal().options.theme) - } - } - ) - - it('keeps a cloned imported theme but reconnects when its selected colors change', async () => { - const custom = { - id: 'manual:preview', - name: 'Preview', - source: 'manual' as const, - mode: 'dark' as const, - terminal: { background: '#123456', foreground: '#eeeeee', red: '#ff0000' }, - importedAt: '2026-09-25' - } - await updateSettings({ - terminalCustomThemes: [custom], - terminalThemeDark: makeCustomTerminalThemeSelection(custom.id) - }) - render() - await act(async () => {}) - expect(terminal().options.theme?.background).toBe('#123456') - await updateSettings({ editorAutoSaveDelayMs: 1234 }) - expect(connect).toHaveBeenCalledOnce() - await updateSettings({ - terminalCustomThemes: [{ ...custom, terminal: { ...custom.terminal, background: '#654321' } }] - }) - expect(connect).toHaveBeenCalledTimes(2) - expect(terminal(1).options.theme?.background).toBe('#654321') - }) - - it('reconnects when the effective OS theme changes', async () => { - await updateSettings({ theme: 'system' }) - const view = render() - await act(async () => {}) - harness.systemPrefersDark = true - view.rerender() - await act(async () => {}) - expect(connect).toHaveBeenCalledTimes(2) - expect(terminal(1).options.theme).not.toEqual(terminal().options.theme) - }) - it('uses current live options when an unrelated update arrives during connect', async () => { const gate = Promise.withResolvers() connect.mockReturnValueOnce(gate.promise) @@ -243,18 +164,6 @@ describe('preview terminal settings lifetime', () => { expect(terminal().options.cursorStyle).toBe('bar') }) - it('ignores an old pending connection after a relevant theme change', async () => { - const gate = Promise.withResolvers() - connect.mockReturnValueOnce(gate.promise) - render() - await updateSettings({ terminalMinimumContrastRatio: 7 }) - expect(connect).toHaveBeenCalledTimes(2) - expect(harness.instances).toHaveLength(1) - await act(async () => gate.resolve(connection)) - expect(harness.instances).toHaveLength(1) - expect(terminal().options.minimumContrastRatio).toBe(7) - }) - it('does not install a terminal after unmounting a pending connection', async () => { const gate = Promise.withResolvers() connect.mockReturnValueOnce(gate.promise) @@ -266,27 +175,4 @@ describe('preview terminal settings lifetime', () => { expect(connect).toHaveBeenCalledOnce() expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('ssh:host@@pty-1') }) - - it('keeps a hidden mounted preview connected through unrelated settings updates', async () => { - render( - - ) - await act(async () => {}) - await updateSettings({ editorAutoSaveDelayMs: 1234 }) - expect(connect).toHaveBeenCalledOnce() - expect(unsubscribe).not.toHaveBeenCalled() - }) - - it('still resyncs the same terminal after unrelated settings updates', async () => { - render() - await act(async () => {}) - await updateSettings({ editorAutoSaveDelayMs: 1234 }) - await act(async () => emitData?.({ type: 'resync', ptyId: 'pty-1' })) - expect(connect).toHaveBeenCalledTimes(2) - expect(harness.instances).toHaveLength(1) - expect(terminal().reset).toHaveBeenCalledOnce() - expect(unsubscribe).not.toHaveBeenCalled() - }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index 26fb3fd733f..6a4fd30a905 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -253,277 +253,6 @@ describe('AgentTerminalPreview', () => { expect(ack).toHaveBeenCalledWith('pty-1', 4) }) - it('installs the macOS IME native-text forwarder and lets its claims bypass chord handling', async () => { - platformState.value = 'darwin' - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - expect(imeHarness.forwarders).toHaveLength(1) - expect(imeHarness.trackers).toHaveLength(1) - - imeHarness.forwarders[0]!.sendInput('。') - expect(terminal.input).toHaveBeenCalledOnce() - expect(input).toHaveBeenCalledOnce() - expect(input).toHaveBeenCalledWith('pty-1', '。') - - // A claimed native-text key bypasses xterm AND the clipboard chords. - imeHarness.claimResult = true - terminal.selectionText = 'selected text' - const handled = terminal.customKeyHandler!( - new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true }) - ) - expect(handled).toBe(false) - expect(writeClipboardText).not.toHaveBeenCalled() - expect(writeTerminalClipboardText).not.toHaveBeenCalled() - - // Unclaimed events still reach the chord handling. - imeHarness.claimResult = false - const copied = terminal.customKeyHandler!( - new KeyboardEvent('keydown', { key: 'c', code: 'KeyC', metaKey: true }) - ) - expect(copied).toBe(false) - expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text') - expect(writeClipboardText).not.toHaveBeenCalled() - }) - - it('does not install the IME native-text forwarder on Windows', async () => { - platformState.value = 'win32' - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - await waitFor(() => expect(terminalHarness.instances[0]!.customKeyHandler).not.toBeNull()) - expect(imeHarness.forwarders).toHaveLength(0) - expect(imeHarness.trackers).toHaveLength(0) - }) - - // fcitx4 delivers a single-character pick as the Space/digit keypress's text (#28213). - it('installs the IME native-text forwarder on desktop Linux', async () => { - platformState.value = 'linux' - render() - await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1)) - }) - - it('hands the forwarder the live mirror seeded from the snapshot flags', async () => { - platformState.value = 'darwin' - connect.mockResolvedValue({ - snapshot: { data: '', cols: 80, rows: 24, seq: 1, kittyKeyboardFlags: 8 }, - replay: [] - }) - render() - await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1)) - await waitFor(() => expect(imeHarness.forwarders[0]!.getKittyKeyboardFlags()).toBe(8)) - // The popout xterm gets the same flags, so its encoder agrees with the mirror. - expect(terminalHarness.instances[0]!.write).toHaveBeenCalledWith( - '\x1b[<99u\x1b[=8u', - expect.any(Function) - ) - - // Live output keeps advancing the same mirror the forwarder reads. - act(() => { - emitData?.({ type: 'data', ptyId: 'pty-1', data: '\x1b[ { - platformState.value = 'darwin' - const view = render() - await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1)) - view.unmount() - expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledTimes(1) - expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledTimes(1) - }) - - it('disposes the IME bridge once when the PTY disappears', async () => { - platformState.value = 'darwin' - connect.mockResolvedValueOnce({ - snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, - replay: [] - }) - connect.mockResolvedValueOnce({ snapshot: null, replay: [] }) - const view = render() - await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1)) - - act(() => emitData?.({ type: 'resync', ptyId: 'pty-1' })) - await waitFor(() => expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledOnce()) - expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledOnce() - - view.unmount() - expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledOnce() - expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledOnce() - }) - - it('copies the terminal selection on the copy chord and blocks xterm handling', async () => { - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - - terminal.selectionText = 'selected text' - const keydown = new KeyboardEvent('keydown', { - key: 'C', - code: 'KeyC', - ctrlKey: true, - shiftKey: true, - cancelable: true - }) - const handled = terminal.customKeyHandler!(keydown) - const keyupHandled = terminal.customKeyHandler!( - new KeyboardEvent('keyup', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true }) - ) - expect(handled).toBe(false) - expect(keyupHandled).toBe(false) - expect(keydown.defaultPrevented).toBe(true) - expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text') - expect(writeClipboardText).not.toHaveBeenCalled() - }) - - it('keeps an empty copy chord from leaking terminal input', async () => { - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - - const handled = terminal.customKeyHandler!( - new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true }) - ) - expect(handled).toBe(false) - expect(writeTerminalClipboardText).not.toHaveBeenCalled() - }) - - it('leaves bare Ctrl+C available to the terminal without a selection', async () => { - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - - const handled = terminal.customKeyHandler!( - new KeyboardEvent('keydown', { key: 'c', code: 'KeyC', ctrlKey: true }) - ) - expect(handled).toBe(true) - expect(writeTerminalClipboardText).not.toHaveBeenCalled() - }) - - it('hands unselected Cmd+C to a kitty app and copies a selection instead', async () => { - platformState.value = 'darwin' - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - const cmdC = (type: string): KeyboardEvent => - new KeyboardEvent(type, { key: 'c', code: 'KeyC', metaKey: true, cancelable: true }) - - // A plain shell's unselected Cmd+C sends nothing. - expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(false) - expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(false) - - act(() => { - emitData?.({ type: 'data', ptyId: 'pty-1', data: '\x1b[>1u', bytes: 5 }) - }) - expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(true) - expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(true) - - // A highlight of blank cells copies no text but is still Orca's selection, as in the pane. - Object.assign(terminal, { hasSelection: () => true }) - expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(false) - expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(false) - expect(writeTerminalClipboardText).not.toHaveBeenCalled() - - terminal.selectionText = 'selected text' - expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(false) - expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(false) - expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text') - }) - - it('selects all terminal text on Cmd+A and blocks xterm handling', async () => { - platformState.value = 'darwin' - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - - const keydown = new KeyboardEvent('keydown', { - key: 'a', - code: 'KeyA', - metaKey: true, - cancelable: true - }) - expect(terminal.customKeyHandler!(keydown)).toBe(false) - expect(keydown.defaultPrevented).toBe(true) - - const repeat = new KeyboardEvent('keydown', { - key: 'a', - code: 'KeyA', - metaKey: true, - repeat: true, - cancelable: true - }) - expect(terminal.customKeyHandler!(repeat)).toBe(false) - expect(repeat.defaultPrevented).toBe(true) - expect(terminal.selectAll).toHaveBeenCalledOnce() - }) - - it('sends the word-kill byte on Ctrl+Backspace and blocks xterm handling', async () => { - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - - const keydown = new KeyboardEvent('keydown', { - key: 'Backspace', - code: 'Backspace', - ctrlKey: true, - cancelable: true - }) - const handled = terminal.customKeyHandler!(keydown) - - expect(handled).toBe(false) - expect(keydown.defaultPrevented).toBe(true) - expect(terminal.input).toHaveBeenCalledWith('\x17') - await waitFor(() => expect(input).toHaveBeenCalledWith('pty-1', '\x17')) - }) - - it('swallows a pane-scoped chord instead of leaking its control byte to the agent', async () => { - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - - // Ctrl+Shift+D splits a pane on Linux; xterm would otherwise send Ctrl+D. - const keydown = new KeyboardEvent('keydown', { - key: 'D', - code: 'KeyD', - ctrlKey: true, - shiftKey: true, - cancelable: true - }) - const handled = terminal.customKeyHandler!(keydown) - - expect(handled).toBe(false) - expect(keydown.defaultPrevented).toBe(true) - expect(terminal.input).not.toHaveBeenCalled() - expect(input).not.toHaveBeenCalled() - }) - - it('dismisses the hosting dialog on the pane-close chord', async () => { - const onRequestClose = vi.fn() - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - - const keydown = new KeyboardEvent('keydown', { - key: 'w', - code: 'KeyW', - ctrlKey: true, - cancelable: true - }) - expect(terminal.customKeyHandler!(keydown)).toBe(false) - expect(keydown.defaultPrevented).toBe(true) - expect(onRequestClose).toHaveBeenCalledOnce() - expect(input).not.toHaveBeenCalled() - }) - it('keeps a native input-source chord from inserting text into the preview', async () => { storeState.keybindings = { 'terminal.switchInputSource': ['Shift+Space'] } const view = render() @@ -573,27 +302,6 @@ describe('AgentTerminalPreview', () => { view.unmount() }) - it('defers Option chords to xterm once the TUI negotiates kitty keyboard mode', async () => { - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - - const altBackspace = (): KeyboardEvent => - new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', altKey: true }) - expect(terminal.customKeyHandler!(altBackspace())).toBe(false) - expect(terminal.input).toHaveBeenCalledWith('\x1b\x7f') - - // The agent's TUI pushes kitty flags (CSI > 1 u) on the live stream. - act(() => { - emitData?.({ type: 'data', ptyId: 'pty-1', data: '\x1b[>1u', bytes: 5 }) - }) - terminal.input.mockClear() - - expect(terminal.customKeyHandler!(altBackspace())).toBe(true) - expect(terminal.input).not.toHaveBeenCalled() - }) - // Snapshot replay can redeliver a kitty push; it must not leave stale flags after the TUI’s single pop. it('does not let a redelivered kitty push outlive the TUI pop', async () => { connect.mockResolvedValueOnce({ @@ -618,36 +326,6 @@ describe('AgentTerminalPreview', () => { expect(terminal.input).toHaveBeenCalledWith('\x1b\x7f') }) - it('scrolls the viewport on the macOS scrollback chord', async () => { - platformState.value = 'darwin' - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - - const handled = terminal.customKeyHandler!( - new KeyboardEvent('keydown', { key: 'ArrowUp', code: 'ArrowUp', metaKey: true }) - ) - - expect(handled).toBe(false) - expect(terminal.scrollToTop).toHaveBeenCalled() - expect(terminal.input).not.toHaveBeenCalled() - }) - - it('leaves an unmodified Backspace to xterm', async () => { - render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) - - const handled = terminal.customKeyHandler!( - new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', cancelable: true }) - ) - - expect(handled).toBe(true) - expect(terminal.input).not.toHaveBeenCalled() - }) - it('keeps the existing terminal visible while a resync snapshot is captured', async () => { let resolveRefresh!: (value: TerminalPreviewConnectResult) => void connect @@ -704,56 +382,6 @@ describe('AgentTerminalPreview', () => { expect(unsubscribe).toHaveBeenCalledWith('pty-1') }) - it('does not claim a remote pane closed when no snapshot can exist for it', async () => { - connect.mockResolvedValueOnce({ snapshot: null, replay: [] }) - const view = render() - - await waitFor(() => expect(view.getByText(/remote session/)).toBeInTheDocument()) - expect(view.queryByText(/pane has closed/)).not.toBeInTheDocument() - }) - - it('connects a replacement pty after the previous pty was gone', async () => { - connect.mockResolvedValueOnce({ snapshot: null, replay: [] }).mockResolvedValueOnce({ - snapshot: { data: 'replacement', cols: 80, rows: 24, seq: 1 }, - replay: [] - }) - const view = render() - await waitFor(() => expect(view.getByText(/No preview available/)).toBeInTheDocument()) - - view.rerender() - - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - expect(connect).toHaveBeenLastCalledWith('pty-live', { scrollbackRows: 24 }) - expect(view.queryByText(/No preview available/)).not.toBeInTheDocument() - }) - - it('claims a grid sized to the dialog box and never re-requests an unchanged target', async () => { - vi.useFakeTimers() - const view = render() - await vi.waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - - const host = view.container.querySelector('.origin-bottom-left')! - const box = host.parentElement! - Object.defineProperty(box, 'clientWidth', { configurable: true, value: 900 }) - Object.defineProperty(box, 'clientHeight', { configurable: true, value: 480 }) - // 80×24 grid rendered at 800×384 → 10×16 cells → the box holds 90×30. - const screen = document.createElement('div') - screen.className = 'xterm-screen' - Object.defineProperty(screen, 'offsetWidth', { configurable: true, value: 800 }) - Object.defineProperty(screen, 'offsetHeight', { configurable: true, value: 384 }) - host.appendChild(screen) - - await vi.advanceTimersByTimeAsync(200) - expect(fit).toHaveBeenCalledTimes(1) - expect(fit).toHaveBeenCalledWith('pty-1', 90, 30) - - // Unchanged target geometry must not reclaim the grid on reconnect and fight the host. - act(() => emitData?.({ type: 'resync', ptyId: 'pty-1' })) - await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(2)) - await vi.advanceTimersByTimeAsync(400) - expect(fit).toHaveBeenCalledTimes(1) - }) - it('delays repeated capture after an overflow and cancels the retry on unmount', async () => { vi.useFakeTimers() connect.mockResolvedValue({ @@ -777,40 +405,4 @@ describe('AgentTerminalPreview', () => { await vi.advanceTimersByTimeAsync(150) expect(connect).toHaveBeenCalledTimes(2) }) - - it("previews a paired host's pane from that host, not this app's main process", async () => { - render() - - await waitFor(() => expect(multiplexerHarness.subscriptions).toHaveLength(1)) - expect(multiplexerHarness.environments).toEqual(['env-1']) - expect(multiplexerHarness.subscriptions[0]!.terminal).toBe('term-7') - expect(connect).not.toHaveBeenCalled() - - act(() => - multiplexerHarness.subscriptions[0]!.callbacks.onSnapshot('agent screen', { - cols: 100, - rows: 30, - seq: 5 - }) - ) - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - expect(terminal.write).toHaveBeenCalledWith('agent screen', expect.any(Function)) - - act(() => multiplexerHarness.subscriptions[0]!.callbacks.onData('live', { seq: 9 })) - expect(terminal.write).toHaveBeenCalledWith('live', expect.any(Function)) - act(() => { - terminalHarness.userInputListener?.() - terminal.onDataListener?.('y') - }) - expect(multiplexerHarness.sendInput).toHaveBeenCalledWith('y') - }) - - it('shows the unavailable message instead of a blank dialog when the bridge is missing', async () => { - // The web client's fallback proxy resolves every missing bridge call to undefined. - Object.assign(window.api.terminalPreview, { connect: async () => undefined }) - const view = render() - - await waitFor(() => expect(view.getByText(/No preview available/)).toBeInTheDocument()) - }) }) diff --git a/src/renderer/src/components/dashboard-popout/dashboard-preview-link-composition.test.tsx b/src/renderer/src/components/dashboard-popout/dashboard-preview-link-composition.test.tsx index cfce43fa7ec..91e6f13d874 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-preview-link-composition.test.tsx +++ b/src/renderer/src/components/dashboard-popout/dashboard-preview-link-composition.test.tsx @@ -16,12 +16,11 @@ import { mountPreviewLinkInteraction } from './dashboard-preview-dom-test-fixture' import { verifyPreviewLinkPopulation } from './dashboard-preview-link-population-test-fixture' -import { verifyPopoutDefaultLinkPath } from './dashboard-preview-popout-default-test-fixture' -import { Terminal } from '@xterm/xterm' + import { toast } from 'sonner' import { runtimeStatuses } from '@/store/slices/browser-slice-test-harness' -import { openStandaloneTerminal } from '../standalone-terminal/open-standalone-terminal' -import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker' +import type { openStandaloneTerminal } from '../standalone-terminal/open-standalone-terminal' + import { getDefaultSettings } from '../../../../shared/constants' import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness' import { @@ -37,8 +36,7 @@ import { openWorkspaceBrowserTabForLink } from '@/lib/workspace-browser-tab-open import { AgentTerminalPreview } from './AgentTerminalPreview' import { terminalPreviewApiFor } from './terminal-preview-api' import { AgentKanbanBoard } from './AgentKanbanBoard' -import { AgentTerminalPanel } from './AgentTerminalDialog' -import { handleTerminalHttpLink } from '@/components/terminal-pane/terminal-url-link-hit-testing' + import type { DashboardCard } from '../../../../shared/dashboard-snapshot' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -214,42 +212,6 @@ async function output(data: string, ptyId = 'pty-1') { } const osc = `\x1b]8;;${URL_TO_OPEN}\x07hidden label\x1b]8;;\x07` -it('uses the real Dashboard constructor, OSC primer/provider and genuine claim; context Copy and live destinations share the same URI', async () => { - const foreground = vi.fn() - const view = render( - - ) - await waitFor(() => screenFor(view.container)) - await output(`\x1b[?1000h\x1b[?1006h${osc}`) - const screen = screenFor(view.container) - // No hover is delivered: actual capture priming must discover the hidden OSC label. - pointer(screen, 'mousedown') - pointer(screen, 'mouseup') - await waitFor(() => expect(document.querySelector('[data-link-action-popover]')).not.toBeNull()) - expect(document.querySelector('[data-terminal-link-destination]')?.textContent).toBe(URL_TO_OPEN) - expect( - input.mock.calls.flatMap(([, data]) => data).filter((data) => data.startsWith('\x1b[<')) - ).toEqual([]) - fireEvent.click(view.getByRole('button', { name: 'Copy link' })) - await waitFor(() => expect(window.api.ui.writeClipboardText).toHaveBeenCalledWith(URL_TO_OPEN)) - fireEvent.click(view.getByRole('button', { name: /System Browser/ })) - await waitFor(() => expect(openUrl).toHaveBeenCalledWith(URL_TO_OPEN)) - expect(foreground).not.toHaveBeenCalled() - - store.setState((state) => ({ settings: { ...state.settings!, openLinksInApp: true } })) - view.rerender( - - ) - pointer(screen, 'mousemove') - pointer(screen, 'contextmenu', { button: 2, buttons: 0 }) - await waitFor(() => expect(view.getByRole('button', { name: /Orca Browser/ })).toBeTruthy()) - fireEvent.click(view.getByRole('button', { name: /Orca Browser/ })) - await waitFor(() => expect(foreground).toHaveBeenCalledOnce()) - expect(store.getState().browserPagesByWorkspace).not.toEqual({}) - view.unmount() - expect(document.querySelector('[data-link-action-popover]')).toBeNull() -}) - it.each(['actions', 'open'] as const)( 'retains the provider-owned hidden OSC target across unrelated live row output for %s and context', async (behavior) => { @@ -319,56 +281,6 @@ function seedRemoteWorkspace( })) } -it.each([false, true])( - 'keeps paired SSH OSC actions system-only with exact Copy despite the in-app preference (streaming=%s)', - async (streaming) => { - const hostId = 'ssh:private-target' - const ptyId = 'remote:hub-a@@handle-1' - const linkSource = { - ...source, - executionHostId: hostId, - ptyId - } satisfies DashboardPreviewLinkSource - const foreground = vi.fn() - seedRemoteWorkspace(hostId, 'hub-a', streaming) - const props = { ptyId, linkSource, onBrowserForeground: foreground } - const connect = vi.spyOn(terminalPreviewApiFor(ptyId), 'connect') - const view = render() - await waitFor(() => screenFor(view.container)) - await output(osc, ptyId) - const screen = screenFor(view.container) - pointer(screen, 'mousedown') - pointer(screen, 'mouseup') - const system = await view.findByRole('button', { name: /System Browser/ }) - expect(view.queryByRole('button', { name: /Orca Browser|Open in new tab/ })).toBeNull() - fireEvent.click(view.getByRole('button', { name: 'Copy link' })) - await waitFor(() => expect(window.api.ui.writeClipboardText).toHaveBeenCalledWith(URL_TO_OPEN)) - fireEvent.click(system) - await waitFor(() => expect(openUrl).toHaveBeenCalledExactlyOnceWith(URL_TO_OPEN)) - pointer(screen, 'mousedown', { ctrlKey: true }) - pointer(screen, 'mouseup', { ctrlKey: true }) - await waitFor(() => expect(openUrl).toHaveBeenCalledTimes(2)) - expect(foreground).not.toHaveBeenCalled() - expect(toast.error).not.toHaveBeenCalled() - const wrongPtyId = 'remote:hub-b@@handle-1' - view.rerender( - - ) - await waitFor(() => expect(connect).toHaveBeenCalledTimes(2)) - await output(osc, wrongPtyId) - const wrongScreen = screenFor(view.container) - pointer(wrongScreen, 'mousedown', { ctrlKey: true }) - pointer(wrongScreen, 'mouseup', { ctrlKey: true }) - pointer(wrongScreen, 'contextmenu', { button: 2 }) - expect(document.querySelector('[data-link-action-popover]')).toBeNull() - expect(openUrl).toHaveBeenCalledTimes(2) - } -) - it('gives one useful refusal when a real saved chooser action loses runtime browser eligibility', async () => { const hostId = 'runtime:e' const ptyId = 'remote:e@@handle-1' @@ -398,126 +310,6 @@ it('gives one useful refusal when a real saved chooser action loses runtime brow expect(input.mock.calls.map(([, data]) => data)).toEqual([]) }) -it('keeps None/middle/drag/nonlink reports and ordinary input live, refuses parser queries, and leaves native/paste context alone', async () => { - store.setState((state) => ({ - settings: { ...state.settings!, terminalLinkClickBehavior: 'none' } - })) - const view = render() - await waitFor(() => screenFor(view.container)) - await output(`\x1b[?1000h\x1b[?1006h${URL_TO_OPEN}`) - const screen = screenFor(view.container) - pointer(screen, 'mousedown') - pointer(screen, 'mouseup') - pointer(screen, 'mousedown', { button: 1, buttons: 4 }) - pointer(screen, 'mouseup', { button: 1 }) - await waitFor(() => - expect(input.mock.calls.some(([, data]) => data.startsWith('\x1b[<0;'))).toBe(true) - ) - expect(input.mock.calls.map(([, data]) => data)).toEqual([ - '\x1b[<0;1;1M', - '\x1b[<0;1;1m', - '\x1b[<1;1;1M', - '\x1b[<1;1;1m' - ]) - expect(document.querySelector('[data-link-action-popover]')).toBeNull() - expect(openUrl).not.toHaveBeenCalled() - const prior = input.mock.calls.length - await output('\x1b[6n') - expect(input).toHaveBeenCalledTimes(prior) - const textarea = view.container.querySelector('textarea')! - fireEvent.keyDown(textarea, { key: 'x', code: 'KeyX', keyCode: 88 }) - fireEvent.input(textarea, { data: 'x', inputType: 'insertText' }) - await waitFor(() => expect(input).toHaveBeenCalledWith('pty-1', 'x')) - const native = pointer(screen, 'contextmenu', { button: 2, ctrlKey: true }) - expect(native.defaultPrevented).toBe(false) - store.setState((state) => ({ settings: { ...state.settings!, terminalRightClickToPaste: true } })) - view.rerender() - textarea.focus() - pointer(screen, 'contextmenu', { button: 2 }) - await waitFor(() => expect(window.api.ui.readClipboardText).toHaveBeenCalled()) - expect(document.querySelector('[data-link-action-popover]')).toBeNull() -}) - -it.each([false, true])( - 'preserves standalone normal/SSH defaults and sole-emulator query policy (answersQueries=%s)', - async (answersQueries) => { - const container = document.createElement('div') - document.body.appendChild(container) - const send = vi.fn(() => true) - const instance = openStandaloneTerminal({ - container, - options: { cols: 80, rows: 8, allowProposedApi: true }, - kittyKeyboardModes: new TerminalKittyKeyboardModeTracker({ kittyKeyboard: true }), - sessionKey: 'plain-ssh:test', - sendInput: send, - answersQueries, - getSettings: () => store.getState().settings, - getMacOptionAsAlt: () => 'false', - getTerminalInput: () => null - }) - instances.push(instance) - if (!instance) { - throw new Error('Real constructor refused test layout') - } - const terminal = instance.terminal - await new Promise((resolve) => - instance.write(`${osc}\r\n${URL_TO_OPEN}\x1b[6n`, { live: true, onDone: resolve }) - ) - expect(send.mock.calls.length > 0).toBe(answersQueries) - terminal.input('ordinary') - expect(send).toHaveBeenLastCalledWith('ordinary') - const screen = screenFor(container) - pointer(screen, 'mousemove', { buttons: 0 }) - await waitFor(() => expect(terminal.element?.getAttribute('title')).toBe(URL_TO_OPEN)) - pointer(screen, 'mousedown', { ctrlKey: true }) - pointer(screen, 'mouseup', { ctrlKey: true }) - await waitFor(() => expect(openUrl).toHaveBeenCalledWith(URL_TO_OPEN)) - expect(terminal.hasSelection()).toBe(false) - expect(terminal.element?.getAttribute('title')).toBe(URL_TO_OPEN) - pointer(screen, 'mouseleave') - expect(terminal.element?.getAttribute('title')).toBeNull() - // The same default callback is captured by the detected-URL provider, not only OSC. - pointer(screen, 'mousemove', { clientY: 27 }) - pointer(screen, 'mousedown', { ctrlKey: true, clientY: 27 }) - pointer(screen, 'mouseup', { ctrlKey: true, clientY: 27 }) - await waitFor(() => expect(openUrl).toHaveBeenCalledTimes(2)) - expect(openUrl.mock.calls.map(([url]) => url)).toEqual([URL_TO_OPEN, URL_TO_OPEN]) - pointer(screen, 'mousedown', { clientY: 27 }) - pointer(screen, 'mouseup', { clientY: 27 }) - expect(openUrl).toHaveBeenCalledTimes(2) - const injected = mountedInteraction() - const injectedInteraction = injected.install() - const raw = 'https:example.com' - for (const current of [instance, injected.instance]) { - const term = current.terminal - const target = screenFor(term.element!.parentElement!) - pointer(target, 'mouseleave') - await new Promise((resolve) => - current.write(`\x1b[2J\x1b[H\x1b]8;;${raw}\x07Link\x1b]8;;\x07`, { - live: true, - onDone: resolve - }) - ) - pointer(target, 'mousemove', { buttons: 0 }) - await waitFor(() => expect(term.element?.getAttribute('title')).toBe(raw)) - term.select(0, 0, 4) - const clear = vi.spyOn(term, 'clearSelection') - pointer(target, 'mousedown', { ctrlKey: true }) - pointer(target, 'mouseup', { ctrlKey: true }) - await waitFor(() => expect(openUrl).toHaveBeenLastCalledWith(raw)) - expect(clear).toHaveBeenCalled() - expect(term.hasSelection()).toBe(false) - clear.mockRestore() - } - expect(openUrl.mock.calls.map(([url]) => url)).toEqual([URL_TO_OPEN, URL_TO_OPEN, raw, raw]) - expect(injected.request).not.toHaveBeenCalled() - expect(document.querySelector('[data-link-action-popover]')).toBeNull() - injectedInteraction.dispose() - instance.dispose() - container.remove() - } -) - function mountedInteraction() { return mountPreviewLinkInteraction( source, @@ -592,88 +384,6 @@ function cards(count: number): DashboardCard[] { })) } -it('adds no provider or preview connection when the real board grows from 10 to 100 cards without an open preview', async () => { - const provider = vi.spyOn(Terminal.prototype, 'registerLinkProvider') - const view = render() - view.rerender() - expect(provider).not.toHaveBeenCalled() - expect(window.api.terminalPreview.connect).not.toHaveBeenCalled() - view.unmount() - await verifyPopoutDefaultLinkPath({ - source, - card: { ...cards(1)[0]!, ...source }, - provider, - osc, - readSettings: () => store.getState().settings, - trackInstance: (instance) => instances.push(instance), - pointer, - screenFor, - output, - input, - openUrl, - subscribeToRouter: mocks.subscribe - }) -}) - -it('honors primary/alternate and Open on real literal URLs, keeps background context, and refuses unqualified/mismatched hosts before feedback', async () => { - store.setState((state) => ({ - settings: { ...state.settings!, openLinksInApp: true, terminalLinkClickBehavior: 'open' } - })) - const foreground = vi.fn() - const view = render( - - ) - await waitFor(() => screenFor(view.container)) - await output(URL_TO_OPEN) - const screen = screenFor(view.container) - // First literal click exercises the fallback when the provider has not finished hover discovery. - pointer(screen, 'mousedown') - pointer(screen, 'mouseup') - await waitFor(() => expect(foreground).toHaveBeenCalledOnce()) - expect(document.querySelector('[data-link-action-popover]')).toBeNull() - expect(openUrl).not.toHaveBeenCalled() - pointer(screen, 'mousedown', { ctrlKey: true, shiftKey: true }) - pointer(screen, 'mouseup', { ctrlKey: true, shiftKey: true }) - await waitFor(() => expect(openUrl).toHaveBeenCalledExactlyOnceWith(URL_TO_OPEN)) - expect(foreground).toHaveBeenCalledOnce() - store.setState((state) => ({ - settings: { ...state.settings!, openLinksInAppInBackground: true } - })) - view.rerender( - - ) - const observed = mocks.subscribe.mock.calls.length - pointer(screen, 'mousedown', { ctrlKey: true }) - pointer(screen, 'mouseup', { ctrlKey: true }) - await waitFor(() => expect(mocks.subscribe.mock.results[observed]?.value).toHaveBeenCalledOnce()) - expect(foreground).toHaveBeenCalledOnce() - view.rerender( - - ) - pointer(screen, 'mousedown') - pointer(screen, 'mouseup') - expect(document.querySelector('[data-link-action-popover]')).toBeNull() - expect(openUrl).toHaveBeenCalledTimes(1) - const remotePty = 'ssh:other@@pty-1' - view.rerender( - - ) - await waitFor(() => expect(window.api.terminalPreview.connect).toHaveBeenCalledTimes(2)) - await output(URL_TO_OPEN, remotePty) - pointer(screenFor(view.container), 'contextmenu', { button: 2 }) - // This real-shaped remote card has no eligible managed-browser route in the local-only workspace. - await waitFor(() => expect(view.getByRole('button', { name: /System Browser/ })).toBeTruthy()) - expect(view.queryByRole('button', { name: /Orca Browser/ })).toBeNull() -}) - it('refuses real selected/drag/nonlink reports and retains the real in-window Board chooser and Settings handoff', async () => { const preview = mountedInteraction() const interaction = preview.install() @@ -728,65 +438,3 @@ it('refuses real selected/drag/nonlink reports and retains the real in-window Bo expect(store.getState().activeView).toBe('settings') await waitFor(() => expect(document.querySelector('.xterm')).toBeNull()) }) - -it('hands Settings through the actual in-window frame and shared popover without leaving the covering panel open', async () => { - const close = vi.fn() - const handoff = vi.fn() - const card = { - ...cards(1)[0]!, - ...source, - paneKey: 'pane-1', - leafId: 'leaf-1', - worktreeName: 'Workspace' - } - const view = render( - - ) - await waitFor(() => screenFor(view.container)) - await output(osc) - const screen = screenFor(view.container) - pointer(screen, 'mousedown') - pointer(screen, 'mouseup') - await waitFor(() => - expect(view.getByRole('button', { name: 'Terminal link settings' })).toBeTruthy() - ) - fireEvent.click(view.getByRole('button', { name: 'Terminal link settings' })) - expect(close).toHaveBeenCalledExactlyOnceWith(false) - expect(handoff).toHaveBeenCalledOnce() - expect(store.getState().activeView).toBe('settings') -}) - -it.each(['local', 'unknown'] as const)( - 'keeps the common pane route and real Dashboard route destination/refusal equivalent for source=%s', - async (owner) => { - store.setState((state) => ({ settings: { ...state.settings!, openLinksInApp: false } })) - handleTerminalHttpLink(URL_TO_OPEN, new MouseEvent('mouseup', { button: 0, ctrlKey: true }), { - worktreeId: WORKSPACE, - sourceTabId: 'source', - sourceOwner: owner === 'local' ? { kind: 'local' } : { kind: 'unknown' }, - actionDestinations: { primary: 'system' } - }) - const expected = owner === 'local' ? [URL_TO_OPEN] : [] - expect(openUrl.mock.calls.map(([url]) => url)).toEqual(expected) - openUrl.mockClear() - const view = render( - - ) - await waitFor(() => screenFor(view.container)) - await output(osc) - const screen = screenFor(view.container) - pointer(screen, 'mousedown', { ctrlKey: true }) - pointer(screen, 'mouseup', { ctrlKey: true }) - expect(openUrl.mock.calls.map(([url]) => url)).toEqual(expected) - expect(document.querySelector('[data-link-action-popover]')).toBeNull() - } -) diff --git a/src/renderer/src/components/dashboard-popout/dashboard-preview-popout-default-test-fixture.tsx b/src/renderer/src/components/dashboard-popout/dashboard-preview-popout-default-test-fixture.tsx deleted file mode 100644 index b32fa4ee52a..00000000000 --- a/src/renderer/src/components/dashboard-popout/dashboard-preview-popout-default-test-fixture.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { act, fireEvent, render as renderWithoutApp, waitFor } from '@testing-library/react' -import { expect, vi, type Mock, type MockInstance } from 'vitest' -import type { Terminal } from '@xterm/xterm' -import { toast } from 'sonner' -import { URL_TO_OPEN } from '@/lib/browser-link-open.test-fixture' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' -import * as standalone from '../standalone-terminal/open-standalone-terminal' -import type { DashboardPreviewLinkSource } from './dashboard-preview-http-actions' -import { - mountPreviewLinkInteraction, - observePreviewListeners -} from './dashboard-preview-dom-test-fixture' -import { DashboardPopoutRoot } from './DashboardPopoutRoot' - -export async function verifyPopoutDefaultLinkPath({ - source, - card, - provider, - osc, - readSettings, - trackInstance, - pointer, - screenFor, - output, - input, - openUrl, - subscribeToRouter -}: { - source: DashboardPreviewLinkSource - card: DashboardCard - provider: MockInstance - osc: string - readSettings: () => GlobalSettings | null - trackInstance: (instance: standalone.StandaloneTerminal | null) => void - pointer: (screen: HTMLElement, type: string, options?: MouseEventInit) => MouseEvent - screenFor: (container: HTMLElement) => HTMLElement - output: (data: string, ptyId?: string) => Promise - input: Mock<(pty: string, data: string) => Promise> - openUrl: Mock<(url: string) => Promise> - subscribeToRouter: Mock -}): Promise { - const bridges = new Set() - const subscribe = () => { - const owner = {} - bridges.add(owner) - return () => bridges.delete(owner) - } - let snapshot: ((value: DashboardSnapshot) => void) | undefined - Object.assign(window.api, { - dashboard: { - ...window.api.dashboard, - onSnapshot: (listener: typeof snapshot) => { - snapshot = listener - return subscribe() - }, - requestSnapshot: vi.fn(async () => {}) - }, - agentStatus: { onSet: subscribe, onClear: subscribe }, - ui: { - ...window.api.ui, - onAppMenuPaste: subscribe, - onEditableContextPaste: subscribe, - onAppMenuSelectionAction: subscribe - }, - telemetryGetConsentState: async () => ({ effective: 'disabled', reason: 'ci' }) - }) - const constructor = vi.spyOn(standalone, 'openStandaloneTerminal') - const baseline = mountPreviewLinkInteraction(source, readSettings, trackInstance, false) - expect(constructor.mock.calls[0]![0].linkHandlers).toBeUndefined() - expect(provider).toHaveBeenCalledTimes(1) - baseline.instance.dispose() - baseline.container.remove() - const census = observePreviewListeners([]) - const popout = renderWithoutApp() - act(() => snapshot?.({ generatedAt: 3, cards: [card] })) - expect(bridges.size).toBe(6) - const rootOwners = Array.from(census.active) - fireEvent.click(popout.getByRole('button', { name: /Workspace 0/ })) - expect(window.api.dashboard.ackAgent).toHaveBeenCalledExactlyOnceWith('pane-0', 'explicit') - await waitFor(() => screenFor(document.body)) - const terminal = constructor.mock.results[1]!.value!.terminal - expect(constructor.mock.calls[1]![0].linkHandlers).toBeUndefined() - expect(provider).toHaveBeenCalledTimes(2) - const raw = 'https:example.com' - await output( - `\x1b[?1000h\x1b[?1006h${osc.replace(URL_TO_OPEN, raw)}\r\n${URL_TO_OPEN}\r\n/tmp/plain.txt` - ) - const screen = screenFor(document.body) - pointer(screen, 'mousemove', { buttons: 0 }) - await waitFor(() => expect(terminal.element?.getAttribute('title')).toBe(raw)) - pointer(screen, 'mousedown') - pointer(screen, 'mouseup') - pointer(screen, 'mousedown', { button: 1, buttons: 4 }) - pointer(screen, 'mouseup', { button: 1 }) - expect(pointer(screen, 'contextmenu', { button: 2, ctrlKey: true }).defaultPrevented).toBe(false) - await waitFor(() => - expect(input.mock.calls.map(([, data]) => data)).toEqual([ - '\x1b[<0;1;1M', - '\x1b[<0;1;1m', - '\x1b[<1;1;1M', - '\x1b[<1;1;1m' - ]) - ) - expect(openUrl).not.toHaveBeenCalled() - openUrl.mockRejectedValueOnce(new Error('System unavailable')) - for (const [clientY, uri] of [ - [7, raw], - [27, URL_TO_OPEN] - ] as const) { - pointer(screen, 'mousemove', { buttons: 0, clientY }) - await waitFor(() => expect(terminal.element?.getAttribute('title')).toBe(uri)) - pointer(screen, 'mousedown', { ctrlKey: true, shiftKey: true, clientY }) - pointer(screen, 'mouseup', { ctrlKey: true, shiftKey: true, clientY }) - } - await waitFor(() => expect(openUrl.mock.calls.map(([uri]) => uri)).toEqual([raw, URL_TO_OPEN])) - expect(terminal.hasSelection()).toBe(false) - pointer(screen, 'mouseleave') - expect(terminal.element?.getAttribute('title')).toBeNull() - expect(document.querySelector('[data-link-action-popover], [data-sonner-toaster]')).toBeNull() - expect( - popout.queryByRole('button', { name: /Copy link|System Browser|Terminal link settings/ }) - ).toBeNull() - expect(window.api.ui.writeClipboardText).not.toHaveBeenCalled() - expect(subscribeToRouter).not.toHaveBeenCalled() - expect(toast.error).not.toHaveBeenCalled() - fireEvent.click(popout.getByRole('button', { name: 'Close' })) - await waitFor(() => expect(document.querySelector('.xterm')).toBeNull()) - expect(Array.from(census.active)).toEqual(rootOwners) - popout.unmount() - expect(census.active.size).toBe(0) - census.restore() - expect(bridges.size).toBe(0) - expect(window.api.terminalPreview.unsubscribe).toHaveBeenCalledExactlyOnceWith('pty-1') -} diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts index 13df78f5059..77b897f3742 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts @@ -1,5 +1,4 @@ // @vitest-environment happy-dom -// Exercise the real forwarder’s exact commit/release bytes; wiring mocks cannot verify negotiated CSI-u or macOS event ordering. import { Terminal } from '@xterm/xterm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installPreviewImeBridge } from './preview-terminal-ime-bridge' @@ -19,7 +18,6 @@ type Session = { commit: (text: string) => void /** An input event the text system produced without committing text. */ swallow: () => void - xtermKittyFlags: () => number dispose: () => void } @@ -78,12 +76,6 @@ function open(initialFlags: number): Session { keyup: (init = {}) => key('keyup', init), commit: (text) => input(text), swallow: () => input(null), - xtermKittyFlags: () => - ( - terminal as unknown as { - _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } - } - )._core?.coreService?.kittyKeyboard?.flags ?? 0, dispose: () => { bridge.dispose() terminal.dispose() @@ -102,15 +94,6 @@ describe('preview IME bridge outbound bytes', () => { document.body.replaceChildren() }) - it('encodes the commit from the flags proven before any live output', () => { - const session = open(8) - session.keydown() - session.commit(',') - session.keyup() - expect(session.emitted.join('')).toBe('\x1b[44u') - session.dispose() - }) - describe.each([ ['keydown -> insertText -> keyup', false], // Some macOS input sources commit after keyup; preserve press ownership until then. @@ -140,15 +123,6 @@ describe('preview IME bridge outbound bytes', () => { session.dispose() }) - it('owns the release even though xterm holds no kitty state', () => { - const session = open(10) - // Keep this harness’s xterm flags at zero to prove the forwarder emits releases without xterm’s help. - expect(session.xtermKittyFlags()).toBe(0) - type(session) - expect(session.emitted.join('')).toBe('\x1b[44u\x1b[44;1:3u') - session.dispose() - }) - it('reads the mutable getter exactly once, at commit time', () => { const session = open(0) session.keydown() diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx index 34c81c1ac31..07f3e61d19e 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' -import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness' + import type { AgentStatusClearIpcPayload, AgentStatusIpcPayload @@ -58,37 +58,12 @@ const startViewTransition = vi.fn((cb: () => void) => { }) const originalVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState') -const UNHANDLED_REJECTION_SETTLE_MS = 20 - -async function collectUnhandledRejections(run: () => void): Promise { - const reasons: unknown[] = [] - const onUnhandledRejection = (reason: unknown): void => { - reasons.push(reason) - } - - process.on('unhandledRejection', onUnhandledRejection) - try { - run() - await new Promise((resolve) => setTimeout(resolve, UNHANDLED_REJECTION_SETTLE_MS)) - } finally { - process.off('unhandledRejection', onUnhandledRejection) - } - - return reasons -} function hideDocument(): void { Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }) } /** Mimic Radix’s open-dialog attributes so the top-layer conflict guard recognizes the terminal. */ -function openTerminalDialog(): HTMLElement { - const el = document.createElement('div') - el.setAttribute('role', 'dialog') - el.setAttribute('data-state', 'open') - document.body.appendChild(el) - return el -} describe('useDashboardSnapshot', () => { beforeEach(() => { @@ -127,156 +102,6 @@ describe('useDashboardSnapshot', () => { vi.clearAllMocks() }) - it('runs a view transition when a card changes column', () => { - const { result } = renderHook(() => useDashboardSnapshot()) - act(() => apply(snapshot([card({ bucket: 'idle' })]))) - startViewTransition.mockClear() // ignore the initial populate - - act(() => apply(snapshot([card({ bucket: 'working' })]))) - expect(startViewTransition).toHaveBeenCalledTimes(1) - expect(result.current.cards[0].bucket).toBe('working') - }) - - it('skips the view transition — but still applies the update — while the terminal dialog is open', () => { - const { result } = renderHook(() => useDashboardSnapshot()) - act(() => apply(snapshot([card({ bucket: 'idle' })]))) - startViewTransition.mockClear() // ignore the initial populate - openTerminalDialog() - - act(() => apply(snapshot([card({ bucket: 'working' })]))) - // View-transition snapshots paint above any z-index, including the open terminal dialog. - expect(startViewTransition).not.toHaveBeenCalled() - expect(result.current.cards[0].bucket).toBe('working') - }) - - it('skips the view transition — but still applies the update — while the window is hidden', () => { - const { result } = renderHook(() => useDashboardSnapshot()) - act(() => apply(snapshot([card({ bucket: 'idle' })]))) - startViewTransition.mockClear() - hideDocument() - - act(() => apply(snapshot([card({ bucket: 'working' })]))) - expect(startViewTransition).not.toHaveBeenCalled() - expect(result.current.cards[0].bucket).toBe('working') - }) - - it('swallows the ready rejection when the window is occluded mid-transition', async () => { - const { result } = renderHook(() => useDashboardSnapshot()) - act(() => apply(snapshot([card({ bucket: 'idle' })]))) - readyRejection = new DOMException( - 'Transition was aborted because of invalid state', - 'InvalidStateError' - ) - hideDocumentAfterTransitionStart = true - - const reasons = await collectUnhandledRejections(() => { - act(() => apply(snapshot([card({ bucket: 'working' })]))) - }) - - expect(startViewTransition).toHaveBeenCalled() - expect(reasons).toEqual([]) - expect(result.current.cards[0].bucket).toBe('working') - }) - - it('reports an unrelated ready error when the window becomes hidden', async () => { - const { result } = renderHook(() => useDashboardSnapshot()) - act(() => apply(snapshot([card({ bucket: 'idle' })]))) - const readyError = new TypeError('Transition callback failed') - readyRejection = readyError - hideDocumentAfterTransitionStart = true - - const reasons = await collectUnhandledRejections(() => { - act(() => apply(snapshot([card({ bucket: 'working' })]))) - }) - - expect(startViewTransition).toHaveBeenCalled() - expect(reasons).toEqual([readyError]) - expect(result.current.cards[0].bucket).toBe('working') - }) - - it('reports an InvalidStateError when the visible transition fails', async () => { - const { result } = renderHook(() => useDashboardSnapshot()) - act(() => apply(snapshot([card({ bucket: 'idle' })]))) - const readyError = new DOMException( - 'Duplicate transition names are not allowed', - 'InvalidStateError' - ) - readyRejection = readyError - - const reasons = await collectUnhandledRejections(() => { - act(() => apply(snapshot([card({ bucket: 'working' })]))) - }) - - expect(startViewTransition).toHaveBeenCalled() - expect(reasons).toEqual([readyError]) - expect(result.current.cards[0].bucket).toBe('working') - }) - - // Throttled snapshots omit unchanged icons; retain the previous map rather than losing icons on the next tick. - it('retains the last repo icons when a republish omits them', () => { - const icons = { - r1: { type: 'image' as const, src: 'data:image/png;base64,AAAA', source: 'upload' as const } - } - const { result } = renderHook(() => useDashboardSnapshot()) - - act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: icons })) - expect(result.current.repoIconsByRepoId).toEqual(icons) - - act(() => apply(snapshot([card({ task: 'moved on' })]))) - expect(result.current.repoIconsByRepoId).toEqual(icons) - expect(result.current.cards[0].task).toBe('moved on') - }) - - it('replaces retained icons when the bridge sends a new map', () => { - const first = { - r1: { type: 'image' as const, src: 'data:image/png;base64,AAAA', source: 'upload' as const } - } - const second = { r1: { type: 'emoji' as const, emoji: '🦑' } } - const { result } = renderHook(() => useDashboardSnapshot()) - - act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: first })) - act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: second })) - expect(result.current.repoIconsByRepoId).toEqual(second) - - // An omitted map must retain the NEW icons, not resurrect the old ones. - act(() => apply(snapshot([card({})]))) - expect(result.current.repoIconsByRepoId).toEqual(second) - }) - - // An explicit empty map clears icons; only omission means retention. - it('honours an explicitly empty icon map', () => { - const icons = { r1: { type: 'emoji' as const, emoji: '🦑' } } - const { result } = renderHook(() => useDashboardSnapshot()) - - act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: icons })) - act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: {} })) - expect(result.current.repoIconsByRepoId).toEqual({}) - }) - - it('applies known hook status without requesting a rebuilt snapshot', () => { - const { result } = renderHook(() => useDashboardSnapshot()) - act(() => apply(snapshot([card({ statusUpdatedAt: 100 })]))) - requestSnapshot.mockClear() - - act(() => - applyStatus({ - paneKey: 'pk', - state: 'blocked', - prompt: 'Choose', - connectionId: null, - receivedAt: 300, - stateStartedAt: 250 - }) - ) - - expect(requestSnapshot).not.toHaveBeenCalled() - expect(result.current.cards[0]).toMatchObject({ - bucket: 'attention', - task: 'Choose', - statusUpdatedAt: 300 - }) - }) - it('trailing-debounces an unknown-pane burst into one topology refresh', () => { vi.useFakeTimers() renderHook(() => useDashboardSnapshot()) @@ -333,31 +158,4 @@ describe('useDashboardSnapshot', () => { expect(requestSnapshot).toHaveBeenCalledTimes(1) vi.useRealTimers() }) - - it('refreshes when a monitoring card crosses the stale boundary', () => { - vi.useFakeTimers() - const statusUpdatedAt = 1_000 - vi.setSystemTime(statusUpdatedAt) - renderHook(() => useDashboardSnapshot()) - act(() => - apply( - snapshot([ - card({ - dotState: 'working', - workingMode: 'monitoring', - statusUpdatedAt - }) - ]) - ) - ) - requestSnapshot.mockClear() - - act(() => vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS)) - expect(requestSnapshot).not.toHaveBeenCalled() - act(() => vi.advanceTimersByTime(250)) - expect(requestSnapshot).not.toHaveBeenCalled() - act(() => vi.advanceTimersByTime(1)) - expect(requestSnapshot).toHaveBeenCalledTimes(1) - vi.useRealTimers() - }) }) diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx deleted file mode 100644 index c128024ea9b..00000000000 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ /dev/null @@ -1,614 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import type { ComponentProps } from 'react' -import { describe, expect, it, vi } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types' -import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types' -import { TooltipProvider } from '../ui/tooltip' -import DashboardAgentRow from './DashboardAgentRow' -import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' - -const NOW = 120_000 - -function makeAgent( - overrides: Partial = {}, - entryOverrides: Partial = {} -): DashboardAgentRowData { - const paneKey = overrides.paneKey ?? 'tab-1:leaf-1' - const tab: TerminalTab = { - id: 'tab-1', - ptyId: null, - worktreeId: 'wt-1', - title: 'Terminal 1', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - const entry: AgentStatusEntry = { - state: 'working', - prompt: 'Fix hover scope', - updatedAt: 60_000, - stateStartedAt: 60_000, - agentType: 'codex', - paneKey, - stateHistory: [], - ...entryOverrides - } - - return { - paneKey, - entry, - tab, - agentType: entry.agentType ?? 'codex', - state: entry.state, - startedAt: entry.stateStartedAt, - ...overrides - } -} - -function renderRow(agent: DashboardAgentRowData): string { - return renderToStaticMarkup( - - - - ) -} - -function renderSendTargetRow( - props: Pick< - ComponentProps, - 'sendTargetStatus' | 'sendTargetDisabledReason' - > -): string { - return renderToStaticMarkup( - - - - ) -} - -function classAttributes(markup: string): string[] { - return Array.from(markup.matchAll(/class="([^"]*)"/g), (match) => match[1]) -} - -function classTokens(markup: string): string[] { - return classAttributes(markup).flatMap((className) => className.split(/\s+/).filter(Boolean)) -} - -function hoverSwapClasses(markup: string): string[] { - return classAttributes(markup).filter( - (className) => - className.includes('group-hover') || className.includes('focus-visible:opacity-100') - ) -} - -function dismissButtonClass(markup: string): string { - const match = markup.match(/]*aria-label="Dismiss agent")[^>]*class="([^"]*)"/) - if (!match) { - throw new Error('Expected dismiss agent button in rendered markup') - } - return match[1] -} - -function dismissButtonClassTokens(markup: string): string[] { - return dismissButtonClass(markup).split(/\s+/).filter(Boolean) -} - -function tokenCount(markup: string, token: string): number { - return classTokens(markup).filter((classToken) => classToken === token).length -} - -function classTokensForTaggedElement(markup: string, dataAttribute: string): string[] { - const tagMatch = markup.match(new RegExp(`<[^>]+${dataAttribute}[^>]*>`)) - if (!tagMatch) { - throw new Error(`Expected tagged element for ${dataAttribute}`) - } - const classMatch = tagMatch[0].match(/class="([^"]*)"/) - if (!classMatch) { - throw new Error(`Expected class attribute for ${dataAttribute}`) - } - return classMatch[1].split(/\s+/).filter(Boolean) -} - -describe('DashboardAgentRow', () => { - it('renders orchestration task preview instead of the raw dispatch preamble prompt', () => { - const markup = renderRow( - makeAgent( - {}, - { - prompt: 'You are working inside Orca, a multi-agent IDE.', - orchestration: { - taskId: 'task-1', - dispatchId: 'ctx-1', - taskTitle: 'Checkout race', - displayName: 'Fix checkout race' - } - } - ) - ) - - expect(markup).toContain('Fix checkout race') - expect(markup).not.toContain('You are working inside Orca') - }) - - it('shows the active model beside the agent label', () => { - const markup = renderRow(makeAgent({}, { model: 'gpt-5.4-mini' })) - - expect(markup).toContain('title="gpt-5.4-mini"') - expect(markup).toContain('>gpt-5.4-mini') - expect(classTokens(markup)).toContain('font-mono') - }) - - it('uses the hover background as the focused-pane row highlight', () => { - const markup = renderToStaticMarkup( - - - - ) - - expect(markup).toContain('data-focused-agent-pane="true"') - expect(classTokens(markup)).toContain('worktree-agent-row-hover') - }) - - it('marks eligible send-target rows with an inline send target button', () => { - const markup = renderSendTargetRow({ sendTargetStatus: 'eligible' }) - const tokens = classTokens(markup) - - expect(markup).toContain('data-agent-send-target="eligible"') - expect(tokens).not.toContain('worktree-agent-send-target') - expect(tokens).not.toContain('ring-offset-sidebar') - expect(tokens).toContain('worktree-agent-row-hover') - expect(markup).toContain('aria-label="Send to this agent"') - expect(tokens).toContain('worktree-agent-send-target-button') - expect(tokens).toContain('h-5') - expect(markup).toContain('lucide-send') - expect(markup).not.toContain('aria-label="Dismiss agent"') - }) - - it('marks disabled send-target rows as muted without an eligibility ring', () => { - const markup = renderSendTargetRow({ - sendTargetStatus: 'disabled', - sendTargetDisabledReason: 'Terminal is no longer available' - }) - const tokens = classTokens(markup) - - expect(markup).toContain('data-agent-send-target="disabled"') - expect(markup).toContain('title="Terminal is no longer available • started 1m ago"') - expect(tokens).toContain('cursor-default') - expect(tokens).toContain('opacity-60') - expect(tokens).not.toContain('worktree-agent-send-target') - expect(tokens).not.toContain('ring-offset-sidebar') - expect(markup).not.toContain('aria-label="Send to this agent"') - }) - - it('marks sending rows with a non-clickable progress treatment', () => { - const markup = renderSendTargetRow({ - sendTargetStatus: 'sending', - sendTargetDisabledReason: 'Sending...' - }) - const tokens = classTokens(markup) - - expect(markup).toContain('data-agent-send-target="sending"') - expect(markup).toContain('title="Sending... • started 1m ago"') - expect(tokens).not.toContain('worktree-agent-send-target') - expect(tokens).not.toContain('ring-offset-sidebar') - expect(tokens).toContain('worktree-agent-send-target-button') - expect(tokens).toContain('cursor-progress') - expect(tokens).toContain('opacity-75') - expect(markup).toContain('aria-label="Send to this agent"') - expect(markup).toContain('disabled=""') - }) - - it('scopes the timestamp and dismiss hover swap to the row-owned group', () => { - const markup = renderRow(makeAgent()) - const classes = hoverSwapClasses(markup) - const tokens = classTokens(markup) - - expect(tokens).toContain('group/agent-row') - expect(tokens).toContain('group-hover/agent-row:opacity-0') - expect(dismissButtonClassTokens(markup)).toContain('group-hover/agent-row:opacity-100') - expect(dismissButtonClassTokens(markup)).toContain('focus-visible:opacity-100') - expect(classes.every((className) => !/\bgroup-hover:/.test(className))).toBe(true) - }) - - it('uses the row-owned group for the standalone dismiss control without timestamps', () => { - const markup = renderRow( - makeAgent({ startedAt: 0 }, { updatedAt: 0, stateStartedAt: 0, stateHistory: [] }) - ) - const classes = hoverSwapClasses(markup) - - expect(dismissButtonClassTokens(markup)).toContain('group-hover/agent-row:opacity-100') - expect(dismissButtonClassTokens(markup)).toContain('focus-visible:opacity-100') - expect(classes.every((className) => !/\bgroup-hover:/.test(className))).toBe(true) - }) - - it('renders waiting rows with the shared question glyph', () => { - const markup = renderRow(makeAgent({}, { state: 'waiting' })) - const tokens = classTokens(markup) - - expect(markup).toContain('aria-label="Waiting for input"') - expect(markup).toContain('lucide-message-circle-question-mark') - expect(tokens).toContain('text-agent-question') - expect(tokens).not.toContain('text-amber-500') - expect(tokens).not.toContain('bg-red-500') - }) - - it('keeps blocked rows red', () => { - const markup = renderRow(makeAgent({}, { state: 'blocked' })) - const tokens = classTokens(markup) - - expect(markup).toContain('aria-label="Blocked"') - expect(tokens).toContain('bg-red-500') - expect(tokens).not.toContain('bg-amber-500') - }) - - it('keeps each row hover boundary inside an anonymous ancestor group', () => { - const markup = renderToStaticMarkup( - -
- - -
-
- ) - const classes = hoverSwapClasses(markup) - - expect(markup).toContain('class="group"') - expect(tokenCount(markup, 'group/agent-row')).toBe(2) - expect(tokenCount(markup, 'group-hover/agent-row:opacity-100')).toBe(2) - expect(tokenCount(markup, 'group-hover/agent-row:opacity-0')).toBe(2) - expect(classes.every((className) => !/\bgroup-hover:/.test(className))).toBe(true) - }) - - function renderEndedRow(ending: Partial): string { - return renderRow( - makeAgent( - { state: 'done', startedAt: 1_000 }, - { - state: 'done', - prompt: 'Give me a quick update', - updatedAt: 2_000, - stateStartedAt: 2_000, - stateHistory: [{ state: 'working', prompt: 'Give me a quick update', startedAt: 1_000 }], - ...ending - } - ) - ) - } - - it('renders a crash-cut row as failed, with the red dot and no interrupted tag', () => { - const markup = renderEndedRow({ - mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: 2_000 } - }) - - expect(markup).toContain('aria-label="Failed"') - expect(markup).toContain('bg-red-500') - expect(markup).not.toContain('>interrupted<') - expect(markup).not.toContain('lucide-circle-check') - }) - - it.each([ - ['recorded', { mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 2_000 } }], - ["an old host's flag", { interrupted: true }] - ] as const)( - "renders a user's Stop (%s) as interrupted, with plain text on the secondary line", - (_, ending) => { - const markup = renderEndedRow(ending) - const promptIndex = markup.indexOf('Give me a quick update') - const interruptedIndex = markup.indexOf('>interrupted<') - - expect(markup).toContain('data-slot="tooltip-trigger"') - expect(markup).toContain('aria-label="Interrupted by user"') - expect(markup).not.toContain('data-slot="badge"') - expect(interruptedIndex).toBeGreaterThan(promptIndex) - expect(markup).toContain('bg-muted-foreground') - expect(markup).not.toContain('bg-red-500') - expect(markup).not.toContain('lucide-circle-check') - } - ) - - it('renders a turn a newer request replaced as interrupted, naming no one', () => { - const markup = renderEndedRow({ - mainAgent: { state: 'done', outcome: 'superseded', stateStartedAt: 2_000 } - }) - - expect(markup).toContain('aria-label="Interrupted"') - expect(markup).not.toContain('Interrupted by user') - expect(markup).toContain('>interrupted<') - expect(markup).toContain('bg-muted-foreground') - }) - - it('reserves a real working tool line before tool metadata arrives', () => { - const emptyToolMarkup = renderRow(makeAgent()) - const activeToolMarkup = renderRow( - makeAgent({}, { toolName: 'ListDir', toolInput: '/Users/nwparker/orca' }) - ) - - // Antigravity working hooks can omit tool metadata; preserve the second-line slot to prevent height jumps. - expect(emptyToolMarkup).toContain('data-agent-row-tool-slot=""') - expect(activeToolMarkup).toContain('data-agent-row-tool-slot=""') - expect( - classTokensForTaggedElement(emptyToolMarkup, 'data-agent-row-tool-placeholder="true"') - ).toContain('h-[1lh]') - expect( - classTokensForTaggedElement(activeToolMarkup, 'data-agent-row-tool-header="true"') - ).toContain('h-[1lh]') - expect(emptyToolMarkup).not.toContain('lucide-wrench') - expect(activeToolMarkup).toContain('lucide-wrench') - expect(activeToolMarkup).toContain('ListDir') - }) - - it("says Stopping in place of the tool line while a person's Stop ends the turn", () => { - const markup = renderRow( - makeAgent( - {}, - { - toolName: 'Bash', - toolInput: 'pnpm test', - mainAgent: { state: 'working', stopping: true, stateStartedAt: 60_000 } - } - ) - ) - - expect(markup).toContain('Stopping…') - expect(markup).not.toContain('lucide-wrench') - expect(markup).toContain('data-agent-spinner') - }) - - it('renders monitoring without a spinner or active tool line', () => { - const markup = renderRow( - makeAgent( - {}, - { - workingMode: 'monitoring', - prompt: '', - toolName: 'Bash', - toolInput: 'pnpm dev' - } - ) - ) - - expect(markup).toContain('Monitoring background tasks') - expect(markup).toContain('lucide-activity') - expect(classTokens(markup)).toContain('text-agent-working') - expect(markup).not.toContain('data-agent-spinner') - expect(markup).not.toContain('data-agent-row-tool-slot') - }) - - it('names what a blocked approval is waiting on', () => { - const markup = renderRow( - makeAgent( - { state: 'waiting' }, - { state: 'waiting', toolName: 'bash', toolInput: 'rm -rf build/' } - ) - ) - - // Waiting approvals need the tool line to explain what the amber state asks the reader to approve. - expect(markup).toContain('lucide-wrench') - expect(markup).toContain('bash') - expect(markup).toContain('rm -rf build/') - }) - - it('leaves a wait without tool metadata unchanged', () => { - const markup = renderRow(makeAgent({ state: 'waiting' }, { state: 'waiting' })) - - // Question waits expect no tool metadata; reserve height only for imminent working details. - expect(markup).not.toContain('data-agent-row-tool-slot=""') - expect(markup).not.toContain('lucide-wrench') - }) - - it('keeps a resolved tool off a finished row', () => { - for (const state of ['done', 'blocked', 'idle'] as const) { - const markup = renderRow( - makeAgent( - { state }, - // Why: 'idle' is a row-only state; the entry it is derived from still reports 'done'. - { state: state === 'idle' ? 'done' : state, toolName: 'bash', toolInput: 'rm -rf build/' } - ) - ) - - expect(markup).not.toContain('lucide-wrench') - expect(markup).not.toContain('rm -rf build/') - } - }) - - it('renders orchestration child rows with a connector and tree level', () => { - const markup = renderRow( - makeAgent({ - lineage: { - depth: 1, - isFirstSibling: true, - isLastSibling: true, - childCount: 0 - } - }) - ) - - expect(markup).toContain('data-agent-lineage-connector="last"') - expect(markup).toContain('role="treeitem"') - expect(markup).toContain('aria-level="2"') - expect(classTokens(markup)).toContain('pl-5') - expect(classTokens(markup)).toContain('left-[13px]') - expect(classTokens(markup)).toContain('top-[0.7rem]') - expect(classTokens(markup)).toContain('w-1.5') - expect(classTokens(markup)).toContain('border-l-[1.5px]') - expect(classTokens(markup)).toContain('border-t-[1.5px]') - expect(classTokens(markup)).toContain('border-muted-foreground/45') - }) - - it('annotates parent identity icon when it dispatched children', () => { - const markup = renderToStaticMarkup( - - - - ) - - expect(markup).toContain('title="Codex - dispatched 2 agents"') - expect(markup).toContain('data-agent-lineage-parent-connector="true"') - expect(classTokens(markup)).toContain('left-[13px]') - expect(markup).toContain('aria-level="1"') - }) - - it('marks child-disclosure rows as lineage manager rows', () => { - const markup = renderToStaticMarkup( - - - - ) - - expect(classTokens(markup)).toContain('worktree-agent-lineage-parent-row') - expect(markup).toContain('aria-label="Show 2 child agents"') - }) - - describe('with an OSC 7501 program status', () => { - it('says a block the program reports in place of the tool step', () => { - const markup = renderRow( - makeAgent( - {}, - { - toolName: 'Bash', - toolInput: 'pnpm test', - programStatus: { - state: 'blocked', - kind: 'auth', - message: 'Session expired', - observedAt: 1 - } - } - ) - ) - - expect(markup).toContain('Sign-in required: Session expired') - expect(markup).not.toContain('lucide-wrench') - }) - - it('says nothing for an approval the program reports after the turn finished', () => { - const markup = renderRow( - makeAgent( - {}, - { - state: 'done', - programStatus: { - state: 'blocked', - kind: 'permission', - message: 'Approve plan', - observedAt: 1 - } - } - ) - ) - - expect(markup).not.toContain('Approve plan') - expect(markup).not.toContain('Needs approval') - }) - - it("falls back to the program's message when the row names no tool", () => { - const markup = renderRow( - makeAgent( - {}, - { programStatus: { state: 'working', message: 'Running tests', observedAt: 1 } } - ) - ) - - expect(markup).toContain('data-agent-row-tool-input="true"') - expect(markup).toContain('Running tests') - }) - - it("shows a blocked program row's message, which has no tool step to ride", () => { - const markup = renderRow( - makeAgent( - {}, - { - state: 'blocked', - agentType: 'gh', - programStatus: { - state: 'blocked', - kind: 'auth', - message: 'Run gh auth login', - observedAt: 1 - } - } - ) - ) - - expect(markup).toContain('Sign-in required: Run gh auth login') - }) - - it("keeps a real agent's input without a tool name off the row", () => { - const markup = renderRow(makeAgent({}, { toolInput: 'src/app.ts' })) - - expect(markup).not.toContain('src/app.ts') - expect(markup).toContain('data-agent-row-tool-placeholder="true"') - }) - - it('labels the progress ring with the same percentage the tooltip shows', () => { - const markup = renderRow( - makeAgent({}, { programStatus: { state: 'working', progress: 42, observedAt: 1 } }) - ) - - expect(markup).toContain('role="progressbar"') - expect(markup).toContain('aria-label="Working · 42%"') - expect(markup).not.toContain('>42%<') - }) - }) -})