Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// @vitest-environment happy-dom

import '@testing-library/jest-dom/vitest'

import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import { NativeChatJumpToLatest } from './NativeChatJumpToLatest'

afterEach(cleanup)

// The reader is usually mid-draft when they reach for this: a press must not take the caret.
it('leaves focus in the composer when pressed with the pointer', async () => {
const onJump = vi.fn()
render(
<>
<NativeChatJumpToLatest visible onJump={onJump} />
<textarea aria-label="Message" />
</>
)
const composer = screen.getByRole('textbox', { name: 'Message' })
composer.focus()

await userEvent.click(screen.getByRole('button', { name: 'Jump to latest' }))

// Anti-vacuous: the press did act.
expect(onJump).toHaveBeenCalledOnce()
expect(document.activeElement).toBe(composer)
})

// Mounted while hidden so it can fade out; it must not stay reachable meanwhile.
it('stays mounted but unreachable while hidden', () => {
const { container } = render(<NativeChatJumpToLatest visible={false} onJump={vi.fn()} />)

expect(screen.queryByRole('button', { name: 'Jump to latest' })).toBeNull()
const button = container.querySelector('button')
expect(button).toHaveAttribute('inert')
expect(button).toHaveAttribute('data-shown', 'false')
})
36 changes: 36 additions & 0 deletions src/renderer/src/components/native-chat/NativeChatJumpToLatest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// The way back to the latest message once the reader has scrolled up.
//
// It stays mounted so it can fade rather than pop, and while hidden it is inert
// and hidden from assistive tech, so it can be neither clicked, tabbed to nor read.

import { ArrowDown } from 'lucide-react'
import { translate } from '@/i18n/i18n'

/** Keeps focus where the reader was typing: a pointer press never moves it here. */
function keepFocus(event: React.MouseEvent): void {
event.preventDefault()
}

export function NativeChatJumpToLatest({
visible,
onJump
}: {
visible: boolean
onJump: () => void
}): React.JSX.Element {
const label = translate('components.native-chat.jumpToLatest', 'Jump to latest')
return (
<button
type="button"
data-shown={visible}
inert={!visible}
aria-hidden={!visible || undefined}
onMouseDown={keepFocus}
onClick={onJump}
className="absolute bottom-3 left-1/2 flex h-8 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-popover px-3 text-xs font-medium text-popover-foreground shadow-floating transition-[opacity,translate] duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none data-[shown=false]:pointer-events-none data-[shown=false]:translate-y-1 data-[shown=false]:opacity-0"
>
<ArrowDown className="size-3.5" />
<span>{label}</span>
</button>
)
}
Comment on lines +14 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,120p' src/renderer/src/components/native-chat/NativeChatJumpToLatest.tsx
sed -n '370,435p' src/renderer/src/components/native-chat/NativeChatMessageList.tsx
cat src/renderer/src/components/native-chat/NativeChatJumpToLatest.test.tsx

Repository: stablyai/orca

Length of output: 5715


🏁 Script executed:

git diff --no-ext-diff --unified=50 4de9f9f85a858c711a1513852407354337e1bd36 e16900b2ebfd89afe0aba60336e6787c296ef46d -- src/renderer/src/components/native-chat/NativeChatJumpToLatest.tsx src/renderer/src/components/native-chat/NativeChatMessageList.tsx src/renderer/src/components/native-chat/NativeChatJumpToLatest.test.tsx
printf '\\n--- jump state and handler references ---\\n'
rg -n -C 4 'showJump|jumpToLatest|transcript.*ref|focus\\(' src/renderer/src/components/native-chat/NativeChatMessageList.tsx src/renderer/src/components/native-chat/NativeChatJumpToLatest.tsx src/renderer/src/components/native-chat/NativeChatJumpToLatest.test.tsx
printf '\\n--- focus contract references in native-chat area ---\\n'
rg -n -i -C 2 'focus.*transcript|transcript.*focus|jump.*focus|focus.*jump' src/renderer/src/components/native-chat || test "$?" -eq 1

Repository: stablyai/orca

Length of output: 13681


🏁 Script executed:

printf '%s\\n' '--- jump state and handler definitions ---'
rg -n -F -C 8 'showJump' src/renderer/src/components/native-chat/NativeChatMessageList.tsx
rg -n -F -C 8 'jumpToLatest' src/renderer/src/components/native-chat/NativeChatMessageList.tsx src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts
printf '%s\\n' '--- scroll hook implementation ---'
sed -n '1,260p' src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts
printf '%s\\n' '--- transcript scroll container and focusability ---'
sed -n '345,415p' src/renderer/src/components/native-chat/NativeChatMessageList.tsx
printf '%s\\n' '--- applicable repository focus contract text ---'
rg -n -i -C 2 'focus.*transcript|transcript.*focus|jump.*latest|focus.*restore' docs src/renderer/src/components/native-chat --glob '*.md' --glob '*.tsx' --glob '*.ts' || test "$?" -eq 1

Repository: stablyai/orca

Length of output: 37641


🤖 get_repo_knowledge executed:

get_repo_knowledge stablyai/orca /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/learnings /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/conventions

Length of output: 40334


🌐 Web query:

WHATWG HTML inert attribute focused element becomes inert focus behavior activeElement

💡 Result:

**Short answer:** `inert` prevents an element from being focused, but the WHATWG spec does **not** say that setting `inert` on an element that is *already focused* immediately blurs it or moves focus elsewhere.

The spec says inert nodes generally cannot be focused, and excludes inert elements from being focusable areas. A request to focus an inert target is ignored. ([html.spec.whatwg.org](https://html.spec.whatwg.org/dev/interaction.html?utm_source=openai))

So if the currently focused element becomes inert, `document.activeElement` may still report it: the standard’s `activeElement` algorithm reports the document’s focused area, and the spec doesn’t establish a focus change merely because inertness was added. **Inference:** authors should move focus to a non-inert element themselves when making the focused element’s subtree inert; don’t rely on `activeElement` changing automatically. ([html.spec.whatwg.org](https://html.spec.whatwg.org/multipage/interaction.html?utm_source=openai))

Citations:

- 1: https://html.spec.whatwg.org/dev/interaction.html?utm_source=openai
- 2: https://html.spec.whatwg.org/multipage/interaction.html?utm_source=openai

🏁 Script executed:

printf '%s\\n' '--- exact focus-related component lines ---'
nl -ba src/renderer/src/components/native-chat/NativeChatMessageList.tsx | sed -n '160,180p;225,245p;300,312p;365,420p'
printf '%s\\n' '--- exact scroll-hook lines ---'
nl -ba src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts | sed -n '85,155p'
printf '%s\\n' '--- test path and focus assertions ---'
nl -ba src/renderer/src/components/native-chat/NativeChatJumpToLatest.test.tsx | sed -n '10,42p'
printf '%s\\n' '--- transcript scroller focus attributes ---'
rg -n -F -C 3 'data-native-chat-scroll' src/renderer/src/components/native-chat

Repository: stablyai/orca

Length of output: 26879


Move focus before making the jump button inert.

When a keyboard user activates the button, jumpToLatest calls scrollToBottom, which hides it. The button stays mounted and becomes inert and aria-hidden. HTML does not require inert to move focus from an element that already has focus, so focus can remain on a control hidden from assistive technology. Move focus to the transcript scroll root only when the button has focus. Keep pointer presses from taking focus away from the composer.

Suggested fix
--- a/src/renderer/src/components/native-chat/NativeChatJumpToLatest.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatJumpToLatest.tsx
@@
 export function NativeChatJumpToLatest({
   visible,
-  onJump
+  onJump,
+  buttonRef
 }: {
   visible: boolean
   onJump: () => void
+  buttonRef: React.Ref<HTMLButtonElement>
 }): React.JSX.Element {
@@
     <button
+      ref={buttonRef}
       type="button"
--- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
@@
-  const jumpToLatest = useCallback(() => {
+  const jumpButtonRef = useRef<HTMLButtonElement | null>(null)
+  const jumpToLatest = useCallback(() => {
+    if (document.activeElement === jumpButtonRef.current) {
+      scrollRef.current?.focus()
+    }
     beginNavigation()
     scrollToBottom()
-  }, [beginNavigation, scrollToBottom])
+  }, [beginNavigation, scrollRef, scrollToBottom])
@@
           <div
             ref={scrollRef}
+            tabIndex={-1}
             onScroll={onScroll}
@@
-          <NativeChatJumpToLatest visible={showJump} onJump={jumpToLatest} />
+          <NativeChatJumpToLatest
+            visible={showJump}
+            onJump={jumpToLatest}
+            buttonRef={jumpButtonRef}
+          />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function NativeChatJumpToLatest({
visible,
onJump
}: {
visible: boolean
onJump: () => void
}): React.JSX.Element {
const label = translate('components.native-chat.jumpToLatest', 'Jump to latest')
return (
<button
type="button"
data-shown={visible}
inert={!visible}
aria-hidden={!visible || undefined}
onMouseDown={keepFocus}
onClick={onJump}
className="absolute bottom-3 left-1/2 flex h-8 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-popover px-3 text-xs font-medium text-popover-foreground shadow-floating transition-[opacity,translate] duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none data-[shown=false]:pointer-events-none data-[shown=false]:translate-y-1 data-[shown=false]:opacity-0"
>
<ArrowDown className="size-3.5" />
<span>{label}</span>
</button>
)
}
export function NativeChatJumpToLatest({
visible,
onJump,
buttonRef
}: {
visible: boolean
onJump: () => void
buttonRef: React.Ref<HTMLButtonElement>
}): React.JSX.Element {
const label = translate('components.native-chat.jumpToLatest', 'Jump to latest')
return (
<button
ref={buttonRef}
type="button"
data-shown={visible}
inert={!visible}
aria-hidden={!visible || undefined}
onMouseDown={keepFocus}
onClick={onJump}
className="absolute bottom-3 left-1/2 flex h-8 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-popover px-3 text-xs font-medium text-popover-foreground shadow-floating transition-[opacity,translate] duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none data-[shown=false]:pointer-events-none data-[shown=false]:translate-y-1 data-[shown=false]:opacity-0"
>
<ArrowDown className="size-3.5" />
<span>{label}</span>
</button>
)
}

15 changes: 2 additions & 13 deletions src/renderer/src/components/native-chat/NativeChatMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import {
NATIVE_CHAT_TRANSCRIPT_OUTER_CLASS,
NATIVE_CHAT_TRANSCRIPT_COLUMN_CLASS
} from './native-chat-appearance-style'
import { NativeChatJumpToLatest } from './NativeChatJumpToLatest'
import { useNativeChatRowTypography } from './use-native-chat-row-typography'
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ArrowDown } from 'lucide-react'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
import { translate } from '@/i18n/i18n'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { useNativeChatTranscriptProjection } from './use-native-chat-transcript-projection'
import { structuredQuestionTranscript } from './structured-agent-question-projection'
Expand Down Expand Up @@ -415,17 +414,7 @@ export function NativeChatMessageList({
onReaderScroll={beginNavigation}
pendingId={railHistoryJump.pendingId}
/>
{showJump ? (
<button
type="button"
onClick={jumpToLatest}
aria-label={translate('components.native-chat.jumpToLatest', 'Jump to latest')}
className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowDown className="size-3.5" />
<span>{translate('components.native-chat.jumpToLatest', 'Jump to latest')}</span>
</button>
) : null}
<NativeChatJumpToLatest visible={showJump} onJump={jumpToLatest} />
</div>
{taskListState.list && taskListState.list.tasks.length > 0 ? (
<div className="shrink-0 px-3 pb-2 sm:px-4">
Expand Down
Loading