From 9285de4a410fa77c247c5f41ed3edc91ab88e5cf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 00:09:30 -0500 Subject: [PATCH 1/4] fix(desktop): mark kept-alive panes so document lookups can skip them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inactive tabs stay mounted and hidden with `visibility`, which preserves their layout box — so a background tab answers a document-wide selector with a rect identical to the visible tab's. Tag hidden layers and route those lookups through visible-scoped query helpers. --- .../components/pane-shell/pane-visibility.ts | 27 +++++++++++++++++++ .../pane-shell/tree/renderer/tree-group.tsx | 6 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/components/pane-shell/pane-visibility.ts diff --git a/apps/desktop/src/components/pane-shell/pane-visibility.ts b/apps/desktop/src/components/pane-shell/pane-visibility.ts new file mode 100644 index 000000000000..e16975b5224e --- /dev/null +++ b/apps/desktop/src/components/pane-shell/pane-visibility.ts @@ -0,0 +1,27 @@ +/** + * Keep-alive visibility — the one policy every document-wide lookup must obey. + * + * A tab group keeps each ever-active pane MOUNTED and hides the inactive ones + * with `visibility: hidden` (see `tree/renderer/tree-group.tsx`), deliberately + * preserving their layout box so scroll positions survive a tab round-trip. The + * cost is that an inactive tab's rect is IDENTICAL to the visible tab's, so + * neither selector order nor a rect hit-test can tell them apart. A lookup that + * resolves "the chat surface / composer / viewport" from the document therefore + * has to skip hidden panes, or it silently answers with the wrong tab. + */ + +/** Marks a mounted-but-hidden pane layer (an inactive tab in a stack). */ +export const PANE_HIDDEN_ATTR = 'data-pane-hidden' + +const HIDDEN_PANE = `[${PANE_HIDDEN_ATTR}]` + +/** Spread onto a kept pane layer so the lookups below can skip it. */ +export const hiddenPaneProps = (hidden: boolean): Record => (hidden ? { [PANE_HIDDEN_ATTR]: '' } : {}) + +/** `querySelectorAll` minus anything inside an inactive tab. */ +export const queryAllVisible = (selector: string, root: ParentNode = document): T[] => + [...root.querySelectorAll(selector)].filter(el => !el.closest(HIDDEN_PANE)) + +/** `querySelector` minus anything inside an inactive tab. */ +export const queryVisible = (selector: string, root: ParentNode = document): null | T => + queryAllVisible(selector, root)[0] ?? null diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 0d14929b4210..6df3e2163dfc 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -30,6 +30,7 @@ import { cn } from '@/lib/utils' import { $layoutEditMode } from '../../edit-mode' import { useWindowControlsOverlap } from '../../geometry' +import { hiddenPaneProps } from '../../pane-visibility' import type { DropPosition, GroupNode, RootEdge } from '../model' import { adjacentGroup } from '../model' import { @@ -520,7 +521,9 @@ export function TreeGroup({ {/* Body: the zone's pane content — every kept (ever-active) pane stays mounted in an absolute layer; only the active one is visible. `visibility` (not display) keeps the hidden pane's layout box, so - scroll positions and measurements survive the round-trip. */} + scroll positions and measurements survive the round-trip — which also + makes a hidden layer's rect identical to the visible one's, hence the + marker document-wide lookups filter on (see pane-visibility.ts). */} {!node.minimized && (
{isEmpty ? ( @@ -538,6 +541,7 @@ export function TreeGroup({ aria-hidden={!isActive || undefined} className={cn('absolute inset-0 overflow-auto', !isActive && 'pointer-events-none invisible')} key={paneId} + {...hiddenPaneProps(!isActive)} > {pane?.render ? ( {pane.render()} From 3fbc9bebe17a6fa5629ef7f7602d71e2d8343d34 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 00:09:30 -0500 Subject: [PATCH 2/4] fix(desktop): drop a dragged session on the tab the user can see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drag snapshotted every chat surface and composer in the document, so with a stacked tab group the link/split resolved against whichever pane came first — usually a hidden one — and the @session chip landed in a composer nobody was looking at. --- apps/desktop/src/app/chat/session-drag.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/chat/session-drag.ts b/apps/desktop/src/app/chat/session-drag.ts index f99bfa47325d..b4ca62802de3 100644 --- a/apps/desktop/src/app/chat/session-drag.ts +++ b/apps/desktop/src/app/chat/session-drag.ts @@ -27,6 +27,7 @@ import type { PointerEvent as ReactPointerEvent } from 'react' +import { queryAllVisible } from '@/components/pane-shell/pane-visibility' import { findGroup } from '@/components/pane-shell/tree/model' import { type DoubleTapContext, @@ -66,8 +67,11 @@ const snapRect = (el: HTMLElement): ZoneRect => { return { left: r.left, top: r.top, right: r.right, bottom: r.bottom } } +/** Chat surfaces the pointer can land on. Inactive tabs are excluded: they stay + * mounted with their layout box intact, so their rect is identical to the + * visible tab's and a hit-test alone would pick whichever came first. */ function snapshotSurfaces(): SurfaceSnapshot[] { - return [...document.querySelectorAll('[data-session-anchor]')].map(el => ({ + return queryAllVisible('[data-session-anchor]').map(el => ({ anchor: el.dataset.sessionAnchor || 'workspace', composerTarget: el.dataset.composerTarget || 'main', rect: snapRect(el) @@ -123,7 +127,7 @@ export function startSessionDrag( zones = snapshotZones() strips = snapshotStrips() surfaces = snapshotSurfaces() - composers = [...document.querySelectorAll('[data-slot="composer-root"]')].map(snapRect) + composers = queryAllVisible('[data-slot="composer-root"]').map(snapRect) zoneHost = new Map(zones.map(zone => [zone.id, chatZonePane(zone.id)])) source?.style.setProperty('opacity', '0.45') // The same sentinel the zone overlay + chat surfaces key off — the From 771dfcc083b4696c075dd2470349284235220ece Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 00:09:30 -0500 Subject: [PATCH 3/4] fix(desktop): keep background tabs out of blur, timeline, and key routing Same ambiguity in three more lookups: blurComposerInput could blur a hidden input and leave the focused one alone, the timeline scrolled whichever viewport it found first, and a clarify card waiting in a background thread swallowed the foreground composer's letter keys. --- apps/desktop/src/app/chat/composer/focus.ts | 8 ++++-- .../assistant-ui/thread/timeline.tsx | 26 +++++++++++-------- .../src/lib/keybinds/composer-focus-keys.ts | 15 ++++++++--- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index a470edfcd958..7120fde2b1a6 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -10,6 +10,8 @@ * steal focus from the composer effect. */ +import { queryVisible } from '@/components/pane-shell/pane-visibility' + import type { InlineRefInput } from './inline-refs' import { RICH_INPUT_SLOT } from './rich-editor' @@ -175,9 +177,11 @@ export const focusComposerInput = (el: HTMLElement | null) => { window.setTimeout(focus, 0) } -/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.). */ +/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.). + * Skips inactive tabs — they stay mounted, so an unscoped lookup can land on a + * background composer and leave the visible one focused. */ export const blurComposerInput = () => { - const el = document.querySelector(`[data-slot="${RICH_INPUT_SLOT}"]`) as HTMLElement | null + const el = queryVisible(`[data-slot="${RICH_INPUT_SLOT}"]`) if (el && document.activeElement === el) { el.blur() diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline.tsx b/apps/desktop/src/components/assistant-ui/thread/timeline.tsx index 18b09c63fe3c..b1603076e5ff 100644 --- a/apps/desktop/src/components/assistant-ui/thread/timeline.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/timeline.tsx @@ -101,8 +101,15 @@ function jumpScroll(viewport: HTMLElement, top: number, duration = 170): void { jumpRaf = requestAnimationFrame(step) } -function scrollToPrompt(id: string) { - const viewport = document.querySelector(VIEWPORT) +// A timeline belongs to ONE chat surface, and several are mounted at once — side +// by side in a split, and stacked (hidden but kept alive) as inactive tabs. Walk +// up to this timeline's own surface before looking for the viewport; a +// document-wide lookup scrolls somebody else's thread. +export const ownViewport = (root: HTMLElement | null): HTMLElement | null => + (root?.closest('[data-session-anchor]') ?? document).querySelector(VIEWPORT) + +function scrollToPrompt(root: HTMLElement | null, id: string) { + const viewport = ownViewport(root) const node = viewport?.querySelector(`[data-message-id="${CSS.escape(id)}"]`) if (!viewport || !node) { @@ -139,6 +146,8 @@ export const ThreadTimeline: FC = () => { const [activeIndex, setActiveIndex] = useState(0) const [open, setOpen] = useState(false) const closeTimerRef = useRef(undefined) + const rootRef = useRef(null) + const jump = useCallback((id: string) => scrollToPrompt(rootRef.current, id), []) // Hover sync lives on the DOM, not in React state — the tick and its popover // row are siblings in different subtrees, so a shared index-keyed paint() lights @@ -176,7 +185,7 @@ export const ThreadTimeline: FC = () => { useEffect(() => () => window.clearTimeout(closeTimerRef.current), []) useEffect(() => { - const viewport = document.querySelector(VIEWPORT) + const viewport = ownViewport(rootRef.current) if (!viewport || entries.length === 0) { return @@ -236,20 +245,15 @@ export const ThreadTimeline: FC = () => { data-suppress-pane-reveal="" onMouseEnter={keepOpen} onMouseLeave={closeSoon} + ref={rootRef} role="navigation" > - + diff --git a/apps/desktop/src/lib/keybinds/composer-focus-keys.ts b/apps/desktop/src/lib/keybinds/composer-focus-keys.ts index 6bba44aa34a1..29da02cda58f 100644 --- a/apps/desktop/src/lib/keybinds/composer-focus-keys.ts +++ b/apps/desktop/src/lib/keybinds/composer-focus-keys.ts @@ -7,6 +7,7 @@ */ import { $workspaceIsPage } from '@/app/routes' +import { queryVisible } from '@/components/pane-shell/pane-visibility' import { switcherActive } from '@/store/session-switcher' import { isEditableTarget, isFocusWithin } from './combo' @@ -37,8 +38,15 @@ const ENTER_ACTIVATES = [ '[role="treeitem"]' ].join(',') -const BLOCKING_SURFACE = - '[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[data-radix-popper-content-wrapper],[data-overlay-surface],[data-clarify-choices]' +// Overlays that cover the whole window (portaled to the body, or the overlay +// shell itself) — one anywhere means the composer is behind it. +const BLOCKING_OVERLAY = + '[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[data-radix-popper-content-wrapper],[data-overlay-surface]' + +// Blockers that live INSIDE a chat surface. Inactive tabs stay mounted, so this +// one has to be visible-scoped: a clarify card waiting in a background thread +// must not take the foreground composer's letter keys. +const BLOCKING_IN_SURFACE = '[data-clarify-choices]' /** True when the focused control would normally handle Enter itself. */ export function isActivateOnEnterTarget(target: EventTarget | null): boolean { @@ -59,7 +67,8 @@ export function composerFocusBlockedBySurface(): boolean { switcherActive() || $workspaceIsPage.get() || isFocusWithin('[data-terminal]') || - Boolean(document.querySelector(BLOCKING_SURFACE)) + Boolean(document.querySelector(BLOCKING_OVERLAY)) || + Boolean(queryVisible(BLOCKING_IN_SURFACE)) ) } From 0e1332abbbb627ba4b5fbebc34be481866100d1c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 00:09:30 -0500 Subject: [PATCH 4/4] 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 000000000000..f39879822748 --- /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 000000000000..5e4b7955ee5d --- /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 000000000000..ced51f60f197 --- /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 000000000000..030aa2d8ec62 --- /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 cebf54950e42..7594a12b06d1 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', '')