Merge remote-tracking branch 'origin/main' into brennanb2025/sta-3919-mobile-picker-lag

This commit is contained in:
Brennan Benson
2026-08-27 13:36:08 -07:00
7 changed files with 156 additions and 73 deletions
@@ -464,22 +464,27 @@ describe('SidebarNav', () => {
expect(searchButton?.querySelector('kbd')).toBeNull()
})
it('hides task source shortcuts until the Tasks row is hovered or focused', async () => {
it('keeps task source shortcuts keyboard-reachable and revealed on Tasks row hover or focus', async () => {
const container = await renderSidebarNav()
const tasksButton = getButtonByText(container, 'Tasks')
const shortcuts = tasksButton.querySelector('[aria-label="Open GitHub tasks"]')?.parentElement
const githubShortcut = tasksButton.parentElement?.querySelector<HTMLButtonElement>(
'button[aria-label="Open GitHub tasks"]'
)
expect(githubShortcut).not.toBeNull()
expect(githubShortcut?.tabIndex).toBe(0)
expect(tasksButton.contains(githubShortcut ?? null)).toBe(false)
expect(shortcuts?.className).toContain('hidden')
expect(shortcuts?.className).toContain('group-hover:flex')
expect(shortcuts?.className).toContain('group-focus-within:flex')
const shortcuts = githubShortcut?.parentElement
expect(shortcuts?.className).toContain('can-hover:opacity-0')
expect(shortcuts?.className).toContain('can-hover:group-hover:opacity-100')
expect(shortcuts?.className).toContain('can-hover:group-focus-within:opacity-100')
})
it('hides available Tasks from its sidebar context menu', async () => {
const container = await renderSidebarNav()
const tasksButton = getButtonByText(container, 'Tasks')
expect(tasksButton.getAttribute('aria-disabled')).toBe('false')
const tasksMenu = tasksButton.closest('[data-testid="context-menu"]')
expect(tasksMenu).not.toBeNull()
@@ -488,18 +493,20 @@ describe('SidebarNav', () => {
expect(mocks.updateSettings).toHaveBeenCalledWith({ showTasksButton: false })
})
it('keeps unavailable Tasks context-menu-capable while left click remains inert', async () => {
it('keeps Tasks enabled with no git repos so the page can explain the empty state', async () => {
setSidebarState({ repos: [folderRepo()] })
const container = await renderSidebarNav()
const tasksButton = getButtonByText(container, 'Tasks')
expect(tasksButton.getAttribute('aria-disabled')).toBe('true')
expect(tasksButton.getAttribute('aria-disabled')).toBeNull()
expect(tasksButton.disabled).toBe(false)
expect(tasksButton.querySelectorAll('[role="button"]')).toHaveLength(0)
expect(tasksButton.querySelector('[aria-label="Open GitHub tasks"]')).toBeNull()
expect(tasksButton.className).not.toContain('opacity-50')
expect(
tasksButton.parentElement?.querySelector('button[aria-label="Open GitHub tasks"]')
).not.toBeNull()
await clickButton(tasksButton)
expect(mocks.openTaskPage).not.toHaveBeenCalled()
expect(mocks.openTaskPage).toHaveBeenCalled()
const tasksMenu = tasksButton.closest('[data-testid="context-menu"]')
expect(tasksMenu).not.toBeNull()
@@ -34,36 +34,23 @@ function HideTaskSidebarMenu({ onHide }: { onHide: () => void }): React.JSX.Elem
}
function TaskProviderShortcut({
canBrowseTasks,
label,
onOpen,
children
}: {
canBrowseTasks: boolean
label: string
onOpen: () => void
children: React.ReactNode
}): React.JSX.Element {
return (
<span
role={canBrowseTasks ? 'button' : undefined}
tabIndex={-1}
onClick={(e) => {
e.stopPropagation()
if (!canBrowseTasks) {
return
}
onOpen()
}}
className={cn(
'rounded p-0.5 text-muted-foreground/70',
canBrowseTasks ? 'transition-colors hover:text-foreground' : 'cursor-default'
)}
aria-label={canBrowseTasks ? label : undefined}
aria-hidden={canBrowseTasks ? undefined : true}
<button
type="button"
onClick={onOpen}
className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-worktree-sidebar-ring"
aria-label={label}
>
{children}
</span>
</button>
)
}
@@ -73,7 +60,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null {
const activeView = useAppStore((s) => s.activeView)
const repos = useAppStore((s) => s.repos)
const repoMap = useRepoMap()
const canBrowseTasks = repos.some((repo) => isGitRepoKind(repo))
const showTasksButton = useAppStore((s) => s.settings?.showTasksButton !== false)
const rawVisibleTaskProviders = useAppStore((s) => s.settings?.visibleTaskProviders)
const defaultTaskSource = useAppStore((s) => s.settings?.defaultTaskSource ?? 'github')
@@ -134,7 +120,7 @@ export function SidebarTaskNavButton(): React.JSX.Element | null {
])
const handlePrefetch = React.useCallback(() => {
if (!canBrowseTasks || resolvedDefaultTaskSource !== 'github') {
if (resolvedDefaultTaskSource !== 'github') {
return
}
const activeRepo = activeRepoId ? (repoMap.get(activeRepoId) ?? null) : null
@@ -150,7 +136,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null {
}
}, [
activeRepoId,
canBrowseTasks,
defaultTaskViewPreset,
prefetchWorkItems,
repoMap,
@@ -171,38 +156,36 @@ export function SidebarTaskNavButton(): React.JSX.Element | null {
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={() => {
if (!canBrowseTasks) {
return
}
openTaskPage()
}}
onPointerEnter={handlePrefetch}
onFocus={handlePrefetch}
aria-disabled={!canBrowseTasks}
aria-current={tasksActive ? 'page' : undefined}
data-contextual-tour-target="sidebar-tasks"
className={cn(
'group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
tasksActive
? 'bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground'
: 'text-worktree-sidebar-foreground/60 hover:bg-worktree-sidebar-foreground/8',
!canBrowseTasks && 'cursor-not-allowed opacity-50 hover:bg-transparent'
)}
>
<List
className={cn('size-4 shrink-0', !tasksActive && 'text-worktree-sidebar-foreground/30')}
strokeWidth={tasksActive ? 2.25 : 1.75}
/>
<span className="flex-1">
{translate('auto.components.sidebar.SidebarNav.fee535205b', 'Tasks')}
</span>
<span className="hidden items-center gap-1 group-hover:flex group-focus-within:flex">
{/* Why: shortcuts sit beside the Tasks button, not inside it, so each stays keyboard-reachable. */}
<div className="group relative">
<button
type="button"
onClick={() => openTaskPage()}
onPointerEnter={handlePrefetch}
onFocus={handlePrefetch}
aria-current={tasksActive ? 'page' : undefined}
data-contextual-tour-target="sidebar-tasks"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
tasksActive
? 'bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground'
: 'text-worktree-sidebar-foreground/60 group-hover:bg-worktree-sidebar-foreground/8'
)}
>
<List
className={cn(
'size-4 shrink-0',
!tasksActive && 'text-worktree-sidebar-foreground/30'
)}
strokeWidth={tasksActive ? 2.25 : 1.75}
/>
<span className="flex-1">
{translate('auto.components.sidebar.SidebarNav.fee535205b', 'Tasks')}
</span>
</button>
<span className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1 can-hover:pointer-events-none can-hover:opacity-0 can-hover:group-hover:pointer-events-auto can-hover:group-hover:opacity-100 can-hover:group-focus-within:pointer-events-auto can-hover:group-focus-within:opacity-100">
{visibleTaskProviders.includes('github') ? (
<TaskProviderShortcut
canBrowseTasks={canBrowseTasks}
label={translate(
'auto.components.sidebar.SidebarNav.0ccba862b8',
'Open GitHub tasks'
@@ -214,7 +197,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null {
) : null}
{visibleTaskProviders.includes('gitlab') ? (
<TaskProviderShortcut
canBrowseTasks={canBrowseTasks}
label={translate(
'auto.components.sidebar.SidebarNav.196c1b5362',
'Open GitLab tasks'
@@ -226,7 +208,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null {
) : null}
{visibleTaskProviders.includes('linear') ? (
<TaskProviderShortcut
canBrowseTasks={canBrowseTasks}
label={translate(
'auto.components.sidebar.SidebarNav.c39ab10000',
'Open Linear tasks'
@@ -238,7 +219,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null {
) : null}
{visibleTaskProviders.includes('jira') ? (
<TaskProviderShortcut
canBrowseTasks={canBrowseTasks}
label={translate(
'auto.components.sidebar.SidebarNav.e7ad3c540d',
'Open Jira tasks'
@@ -249,7 +229,7 @@ export function SidebarTaskNavButton(): React.JSX.Element | null {
</TaskProviderShortcut>
) : null}
</span>
</button>
</div>
</ContextMenuTrigger>
<HideTaskSidebarMenu onHide={hideTasksButton} />
</ContextMenu>
@@ -30,18 +30,23 @@ function keyEvent(init: {
key: string
keyCode: number
ctrlKey?: boolean
metaKey?: boolean
shiftKey?: boolean
altKey?: boolean
isComposing?: boolean
preventDefault?: () => void
stopPropagation?: () => void
}): never {
return {
key: init.key,
keyCode: init.keyCode,
ctrlKey: init.ctrlKey ?? false,
altKey: false,
metaKey: false,
shiftKey: false,
altKey: init.altKey ?? false,
metaKey: init.metaKey ?? false,
shiftKey: init.shiftKey ?? false,
nativeEvent: { isComposing: init.isComposing ?? false },
preventDefault: () => {},
stopPropagation: () => {}
preventDefault: init.preventDefault ?? (() => {}),
stopPropagation: init.stopPropagation ?? (() => {})
} as never
}
@@ -67,6 +72,25 @@ describe('useTabBarQuickCommandSearchInput IME Enter ownership', () => {
expect(onCommandValueChange).toHaveBeenCalledWith(entries[1].key)
})
it('lets the native select-all behavior run in the search input', () => {
const { result } = setup()
const stopPropagation = vi.fn()
const preventDefault = vi.fn()
result.result.current.onKeyDown(
keyEvent({
key: 'a',
keyCode: 65,
ctrlKey: true,
stopPropagation,
preventDefault
})
)
expect(stopPropagation).toHaveBeenCalledOnce()
expect(preventDefault).not.toHaveBeenCalled()
})
it('does not run the command on the bare redispatch after a confirm', () => {
const { onRun, result } = setup()
result.result.current.onCompositionStart()
@@ -1,6 +1,7 @@
import { useCallback, type KeyboardEvent, type RefObject } from 'react'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { isSelectAllShortcut } from '@/lib/editable-target'
type SearchInputOptions<TCommand> = {
commandListRef: RefObject<HTMLDivElement | null>
@@ -33,6 +34,10 @@ export function useTabBarQuickCommandSearchInput<TCommand>({
if (imeEnter.ownsKeyDown(event)) {
return
}
if (isSelectAllShortcut(event)) {
event.stopPropagation()
return
}
if (event.key === 'Enter' && selectedCommand) {
event.preventDefault()
event.stopPropagation()
@@ -2,7 +2,7 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types'
import { TerminalQuickCommandDialog } from './TerminalQuickCommandDialog'
@@ -43,6 +43,10 @@ function findAnimatedRowContaining(text: string): HTMLElement {
}
describe('TerminalQuickCommandDialog animation structure', () => {
beforeEach(() => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
})
afterEach(async () => {
await act(async () => {
for (const root of mountedRoots.splice(0)) {
@@ -50,6 +54,44 @@ describe('TerminalQuickCommandDialog animation structure', () => {
}
})
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
it('selects all text in editable fields with Cmd+A', async () => {
await renderDialog({
id: 'qc-select-all',
label: 'Start dev server',
action: 'terminal-command',
command: 'npm run dev',
appendEnter: true,
scope: { type: 'global' }
})
const fields = [
document.body.querySelector<HTMLInputElement>('input'),
document.body.querySelector<HTMLTextAreaElement>('textarea[aria-label="Command"]')
]
for (const field of fields) {
expect(field).not.toBeNull()
if (!field) {
continue
}
field.setSelectionRange(field.value.length, field.value.length)
await act(async () => {
field.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'a',
code: 'KeyA',
metaKey: true,
bubbles: true,
cancelable: true
})
)
})
expect([field.selectionStart, field.selectionEnd]).toEqual([0, field.value.length])
}
})
it('keeps agent-only fields mounted as collapsed animated rows in terminal mode', async () => {
@@ -22,6 +22,7 @@ import {
import { Label } from '@/components/ui/label'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { isSelectAllShortcut } from '@/lib/editable-target'
import { TerminalQuickCommandActionToggle } from './TerminalQuickCommandActionToggle'
import { TerminalQuickCommandAdvancedSection } from './TerminalQuickCommandAdvancedSection'
import { TerminalQuickCommandContentSection } from './TerminalQuickCommandContentSection'
@@ -196,6 +197,16 @@ export function TerminalQuickCommandDialog({
<div
className="scrollbar-sleek flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 py-5"
onKeyDown={(event) => {
if (
isSelectAllShortcut(event) &&
(event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement)
) {
event.preventDefault()
event.stopPropagation()
event.target.select()
return
}
if (isScreenSubmitShortcut(event) && canSave) {
event.preventDefault()
saveDraft()
+14
View File
@@ -1,3 +1,5 @@
import { getShortcutPlatform } from './shortcut-platform'
// Why: shared across global keyboard listeners (App-level shortcuts and the
// onboarding flow) so an in-progress text edit never gets hijacked by a
// capture-phase keydown handler.
@@ -21,3 +23,15 @@ export function isEditableTarget(target: EventTarget | null): boolean {
null
)
}
export function isSelectAllShortcut(
event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>
): boolean {
if (event.key.toLowerCase() !== 'a' || event.altKey || event.shiftKey) {
return false
}
const platform = getShortcutPlatform()
return platform === 'darwin'
? Boolean(event.metaKey) && !event.ctrlKey
: Boolean(event.ctrlKey) && !event.metaKey
}