From 0e1332abbbb627ba4b5fbebc34be481866100d1c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 00:09:30 -0500 Subject: [PATCH] test(desktop): cover hidden-tab resolution for drops, focus, and timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each case mounts a stacked group with a hidden tab whose geometry matches the visible one — the arrangement that made the original bug invisible to selector order. --- .../src/app/chat/composer/focus.test.ts | 50 ++++++++ .../desktop/src/app/chat/session-drag.test.ts | 113 ++++++++++++++++++ .../assistant-ui/thread/timeline.test.ts | 42 +++++++ .../pane-shell/pane-visibility.test.ts | 41 +++++++ .../lib/keybinds/composer-focus-keys.test.ts | 11 ++ 5 files changed, 257 insertions(+) create mode 100644 apps/desktop/src/app/chat/composer/focus.test.ts create mode 100644 apps/desktop/src/app/chat/session-drag.test.ts create mode 100644 apps/desktop/src/components/assistant-ui/thread/timeline.test.ts create mode 100644 apps/desktop/src/components/pane-shell/pane-visibility.test.ts diff --git a/apps/desktop/src/app/chat/composer/focus.test.ts b/apps/desktop/src/app/chat/composer/focus.test.ts new file mode 100644 index 00000000000..f3987982274 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/focus.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { blurComposerInput } from './focus' +import { RICH_INPUT_SLOT } from './rich-editor' + +/** + * Inactive tabs keep their composer mounted, so an unscoped lookup can blur a + * background input and leave the one the user is typing in focused. + */ + +/** A composer input inside its own pane layer, hidden or not. */ +function mountInput(hidden = false) { + const layer = document.createElement('div') + const input = document.createElement('div') + input.dataset.slot = RICH_INPUT_SLOT + input.tabIndex = 0 + layer.toggleAttribute('data-pane-hidden', hidden) + layer.append(input) + document.body.append(layer) + + return input +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('blurComposerInput', () => { + it('blurs the foreground composer while a hidden tab matches first', () => { + const background = mountInput(true) + const foreground = mountInput() + + foreground.focus() + blurComposerInput() + + expect(document.activeElement).not.toBe(foreground) + expect(document.activeElement).not.toBe(background) + }) + + it('leaves focus alone when the composer does not hold it', () => { + const outside = document.createElement('button') + document.body.append(outside) + mountInput() + + outside.focus() + blurComposerInput() + + expect(document.activeElement).toBe(outside) + }) +}) diff --git a/apps/desktop/src/app/chat/session-drag.test.ts b/apps/desktop/src/app/chat/session-drag.test.ts new file mode 100644 index 00000000000..5e4b7955ee5 --- /dev/null +++ b/apps/desktop/src/app/chat/session-drag.test.ts @@ -0,0 +1,113 @@ +import type { PointerEvent as ReactPointerEvent } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { group } from '@/components/pane-shell/tree/model' +import { $layoutTree } from '@/components/pane-shell/tree/store' +import { openSessionTile } from '@/store/session-states' + +import { requestComposerInsertRefs } from './composer/focus' +import { startSessionDrag } from './session-drag' + +/** + * A session drop resolves its target by rect-testing the chat surfaces in the + * document. A tab group keeps inactive tabs MOUNTED with their layout box + * intact, so a background tab's rect is identical to the foreground tab's — + * the drop has to land on the tab the user can actually see. + */ + +vi.mock('@/store/session-states', () => ({ openSessionTile: vi.fn() })) +vi.mock('./composer/focus', () => ({ requestComposerInsertRefs: vi.fn() })) + +const ZONE = { left: 0, top: 0, right: 1000, bottom: 800 } +const COMPOSER = { left: 100, top: 700, right: 900, bottom: 780 } + +const stubRect = (el: Element, box: { left: number; top: number; right: number; bottom: number }) => { + el.getBoundingClientRect = () => + ({ ...box, width: box.right - box.left, height: box.bottom - box.top, x: box.left, y: box.top }) as DOMRect +} + +/** The workspace tab kept alive behind an active session tile tab. */ +function mountStackedTabs() { + document.body.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+ ` + + stubRect(document.querySelector('[data-tree-group]')!, ZONE) + + for (const surface of document.querySelectorAll('[data-session-anchor]')) { + stubRect(surface, ZONE) + } + + for (const composer of document.querySelectorAll('[data-slot="composer-root"]')) { + stubRect(composer, COMPOSER) + } + + $layoutTree.set(group(['workspace', 'session-tile:visible'], { id: 'g1' })) + + return document.getElementById('row')! +} + +/** Press on `source`, drag to (x, y), release. The drag session flushes its + * pending move synchronously on release, so no frame wait is needed. */ +function dragTo(source: HTMLElement, x: number, y: number) { + startSessionDrag({ id: 'dragged', profile: 'default', title: 'Dragged chat' }, { + button: 0, + clientX: 0, + clientY: 0, + currentTarget: source, + pointerId: 1 + } as unknown as ReactPointerEvent) + + window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: x, clientY: y })) + window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: x, clientY: y })) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + document.body.innerHTML = '' + $layoutTree.set(null) +}) + +describe('session drop targeting across stacked tabs', () => { + it('links into the visible tab’s composer, not the tab kept alive behind it', () => { + const row = mountStackedTabs() + + dragTo(row, 500, 740) + + expect(requestComposerInsertRefs).toHaveBeenCalledWith(expect.anything(), { target: 'tile:visible' }) + }) + + it('docks a split against the visible tab’s pane', () => { + const row = mountStackedTabs() + + dragTo(row, 980, 400) + + expect(openSessionTile).toHaveBeenCalledWith('dragged', 'right', 'session-tile:visible', undefined) + expect(requestComposerInsertRefs).not.toHaveBeenCalled() + }) + + it('commits nothing over a zone that hosts no chat surface', () => { + mountStackedTabs() + $layoutTree.set(group(['terminal'], { id: 'g1' })) + + dragTo(document.getElementById('row')!, 500, 740) + + expect(requestComposerInsertRefs).not.toHaveBeenCalled() + expect(openSessionTile).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline.test.ts b/apps/desktop/src/components/assistant-ui/thread/timeline.test.ts new file mode 100644 index 00000000000..ced51f60f19 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/timeline.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { ownViewport } from './timeline' + +/** + * Several chat surfaces are mounted at once — side by side in a split, and + * stacked as kept-alive inactive tabs. A timeline scrolls its OWN thread. + */ + +afterEach(() => { + document.body.innerHTML = '' +}) + +const surface = (id: string, hidden = false) => ` +
+
+
+
+
+
+` + +describe('ownViewport', () => { + it('resolves the viewport of the surface the timeline lives in', () => { + document.body.innerHTML = surface('workspace') + surface('session-tile:b') + + expect(ownViewport(document.getElementById('timeline-session-tile:b'))?.id).toBe('viewport-session-tile:b') + expect(ownViewport(document.getElementById('timeline-workspace'))?.id).toBe('viewport-workspace') + }) + + it('ignores a kept-alive tab that matches first', () => { + document.body.innerHTML = surface('workspace', true) + surface('session-tile:b') + + expect(ownViewport(document.getElementById('timeline-session-tile:b'))?.id).toBe('viewport-session-tile:b') + }) + + it('falls back to the document when there is no surface around it', () => { + document.body.innerHTML = '
' + + expect(ownViewport(null)?.id).toBe('viewport-lone') + }) +}) diff --git a/apps/desktop/src/components/pane-shell/pane-visibility.test.ts b/apps/desktop/src/components/pane-shell/pane-visibility.test.ts new file mode 100644 index 00000000000..030aa2d8ec6 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/pane-visibility.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { hiddenPaneProps, PANE_HIDDEN_ATTR, queryAllVisible, queryVisible } from './pane-visibility' + +/** + * Inactive tabs stay mounted with their layout box intact, so they answer + * document-wide lookups exactly like the visible tab. These helpers are the one + * place that difference is decided. + */ + +const COMPOSER = '[data-slot="composer-root"]' + +const tab = (id: string, hidden = false) => ` +
+
+
+` + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('pane visibility lookups', () => { + it('resolves the foreground element even when a hidden tab matches first', () => { + document.body.innerHTML = tab('background', true) + tab('foreground') + + expect(queryVisible(COMPOSER)?.id).toBe('foreground') + expect(queryAllVisible(COMPOSER).map(el => el.id)).toEqual(['foreground']) + }) + + it('answers normally when nothing is hidden', () => { + document.body.innerHTML = tab('only') + + expect(queryVisible(COMPOSER)?.id).toBe('only') + }) + + it('marks a pane hidden only while it is inactive', () => { + expect(hiddenPaneProps(true)).toEqual({ [PANE_HIDDEN_ATTR]: '' }) + expect(hiddenPaneProps(false)).toEqual({}) + }) +}) diff --git a/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts b/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts index cebf54950e4..7594a12b06d 100644 --- a/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts +++ b/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts @@ -88,6 +88,17 @@ describe('composerFocusBlockedBySurface', () => { expect(composerFocusBlockedBySurface()).toBe(true) }) + it('ignores a clarify card waiting in a kept-alive background tab', () => { + const tab = document.createElement('div') + tab.setAttribute('data-pane-hidden', '') + const card = document.createElement('div') + card.setAttribute('data-clarify-choices', '') + tab.append(card) + document.body.append(tab) + + expect(composerFocusBlockedBySurface()).toBe(false) + }) + it('blocks when focus is inside a terminal', () => { const term = document.createElement('div') term.setAttribute('data-terminal', '')