mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-3919-mobile-picker-lag
This commit is contained in:
@@ -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()
|
||||
|
||||
+43
-1
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user