Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bc4291d
fix(native-chat): sending brings the latest into view, with visible j…
AmoabaKelvin Oct 2, 2026
ffd5405
fix(native-chat): a late or unsent answer no longer moves a reader wh…
AmoabaKelvin Oct 2, 2026
70a02d5
fix(native-chat): an empty answer no longer moves the reader
AmoabaKelvin Oct 2, 2026
f351fc5
fix(native-chat): preserve upstream retry and question cancellation
nwparker Oct 5, 2026
0ec7978
merge: origin/main into fix/desktop-chat-scrolling
brennanb2025 Oct 5, 2026
b5974c1
refactor(native-chat): leave Jump to top out of this change
brennanb2025 Oct 5, 2026
da90399
refactor(native-chat): keep Jump to latest's existing look in this ch…
brennanb2025 Oct 6, 2026
8281270
Merge remote-tracking branch 'origin/main' into stack-24514-logic
brennanb2025 Oct 6, 2026
334013e
Merge origin/main into native chat scrolling review branch
brennanb2025 Oct 6, 2026
cf26c91
Merge origin/main into native chat scrolling review branch
brennanb2025 Oct 6, 2026
8639591
Preserve native chat reader intent across submissions and disclosure …
brennanb2025 Oct 6, 2026
5441267
Merge current main into native chat scrolling fix
brennanb2025 Oct 6, 2026
ac137ff
fix(native-chat): wait for Resume and recover following on reopen
brennanb2025 Oct 6, 2026
efefff8
fix(native-chat): assert reader takeover before rail alignment
brennanb2025 Oct 6, 2026
04a1286
fix(native-chat): retain reader takeover until its frame ends
brennanb2025 Oct 6, 2026
47a1fd9
Merge main into desktop chat scrolling
brennanb2025 Oct 6, 2026
d080d70
Merge remote-tracking branch 'origin/main' into stack-24514-main2
brennanb2025 Oct 6, 2026
37a5372
fix(native-chat): only a scroll gesture stops following; sends reveal…
brennanb2025 Oct 6, 2026
d862a82
chore: leave an unrelated fixture as main has it
brennanb2025 Oct 6, 2026
e9afe53
Merge remote-tracking branch 'origin/main' into rework-24514-v2
brennanb2025 Oct 6, 2026
5667d90
refactor(native-chat): share the terminal send paths' common options …
brennanb2025 Oct 6, 2026
0b03326
test(native-chat): drop a field the main merge declared twice
brennanb2025 Oct 6, 2026
a699e53
test(native-chat): the pane's steer still steers the queue, and also …
brennanb2025 Oct 6, 2026
938056d
feat(native-chat): a Jump to top button beside Jump to latest
brennanb2025 Oct 6, 2026
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
44 changes: 19 additions & 25 deletions src/renderer/src/components/native-chat/NativeChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
optimisticSendOutcome,
onOptimisticSendCanceled,
onSlashCommand,
onSubmitted,
answerCommandLocally,
onSwitchToTerminal,
readTerminalScreen,
Expand Down Expand Up @@ -225,6 +226,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
agent,
disabled,
onSlashCommand,
onSubmitted,
resolveTarget,
setHistory
})
Expand Down Expand Up @@ -255,30 +257,35 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
setCaret
})

const sendPty = useNativeChatPtyComposerSend({
// What both terminal send paths share: where a command goes and what the composer resets.
const ptyCommandRouting = {
agent,
draft,
imageAttachments,
disabled,
isDispatchingSessionOption,
launchDraft: launchSeed?.launchDraft,
launchDraftResolved: launchSeed?.launchDraftResolved === true,
readTerminalScreen,
resolveTarget,
classifySend,
onOptimisticSend,
optimisticSendOutcome,
onSlashCommand,
onSubmitted,
answerCommandLocally,
sessionOptionsSurface: ptySessionOptionsSurface,
terminalTabId,
trackPendingSend,
setHistory,
setDraft,
setCaret,
clearSkillOrigin,
clearImageAttachments,
setNotice
}
const sendPty = useNativeChatPtyComposerSend({
...ptyCommandRouting,
draft,
imageAttachments,
launchDraft: launchSeed?.launchDraft,
launchDraftResolved: launchSeed?.launchDraftResolved === true,
readTerminalScreen,
classifySend,
onOptimisticSend,
optimisticSendOutcome,
terminalTabId
})
const { send, goalMode } = useNativeChatComposerSubmit({
structuredTransport,
Expand All @@ -303,21 +310,8 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
})

const dispatchPtyPickerCommand = useNativeChatPickerCommandDispatch({
agent,
disabled,
isDispatchingSessionOption,
resolveTarget,
onSlashCommand,
answerCommandLocally,
sessionOptionsSurface: ptySessionOptionsSurface,
trackPendingSend,
setHistory,
setDraft,
setCaret,
setActiveSuggestion,
clearSkillOrigin,
clearImageAttachments,
setNotice
...ptyCommandRouting,
setActiveSuggestion
})
const dispatchPickerCommand = useCallback(
(command: Parameters<typeof dispatchPtyPickerCommand>[0]) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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 {
export function keepFocus(event: React.MouseEvent): void {
event.preventDefault()
}

Expand All @@ -27,7 +27,7 @@ export function NativeChatJumpToLatest({
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"
className="pointer-events-auto flex h-8 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>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom

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

import { cleanup, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import { render } from './native-chat-app-root-test-render'
import { NativeChatJumpToTop } from './NativeChatJumpToTop'

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 startFromBeginning = vi.fn()
render(
<>
<NativeChatJumpToTop visible historyJump={{ startPending: false, startFromBeginning }} />
<textarea aria-label="Message" />
</>
)
const composer = screen.getByRole('textbox', { name: 'Message' })
composer.focus()

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

// Anti-vacuous: the press did act.
expect(startFromBeginning).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(
<NativeChatJumpToTop
visible={false}
historyJump={{ startPending: false, startFromBeginning: vi.fn() }}
/>
)

expect(screen.queryByRole('button', { name: 'Jump to top' })).toBeNull()
const button = container.querySelector('button')
expect(button).toHaveAttribute('inert')
expect(button).toHaveAttribute('data-shown', 'false')
})
49 changes: 49 additions & 0 deletions src/renderer/src/components/native-chat/NativeChatJumpToTop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// The way to the start of the conversation, offered beside "Jump to latest".
//
// Mounted and inert while hidden, like "Jump to latest", so it fades rather than pops.

import { ArrowUp, Loader2 } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { keepFocus } from './NativeChatJumpToLatest'
import type { useNativeChatRailHistoryJump } from './use-native-chat-rail-history-jump'

export function NativeChatJumpToTop({
visible,
historyJump
}: {
visible: boolean
historyJump: Pick<
ReturnType<typeof useNativeChatRailHistoryJump>,
'startPending' | 'startFromBeginning'
>
}): React.JSX.Element {
const pending = historyJump.startPending
const label = translate('components.native-chat.jumpToTop', 'Jump to top')
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
aria-busy={pending || undefined}
data-shown={visible}
inert={!visible}
aria-hidden={!visible || undefined}
onMouseDown={keepFocus}
onClick={historyJump.startFromBeginning}
className="pointer-events-auto flex size-8 items-center justify-center rounded-full border border-border bg-popover 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"
>
{pending ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : (
<ArrowUp className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

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

import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, screen } from '@testing-library/react'
import { render } from './native-chat-app-root-test-render'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { NativeChatMessageList } from './NativeChatMessageList'
Expand Down Expand Up @@ -453,6 +454,7 @@ describe('transcript follow ownership across growth and appends', () => {
const scheduleSpy = vi.spyOn(window, 'requestAnimationFrame')
const scrollToSpy = vi.spyOn(scroller, 'scrollTo')
const readingAt = 2000
fireEvent.wheel(scroller, { deltaY: -100 })
scroller.scrollTop = readingAt
fireEvent.scroll(scroller)
expect(scrollToSpy).toHaveBeenLastCalledWith({ behavior: 'auto', top: readingAt })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

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

import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, screen } from '@testing-library/react'
import { render } from './native-chat-app-root-test-render'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type {
AgentJournalRenderItem,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

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

import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, screen } from '@testing-library/react'
import { render } from './native-chat-app-root-test-render'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { NativeChatMessageList } from './NativeChatMessageList'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

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

import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, screen } from '@testing-library/react'
import { render } from './native-chat-app-root-test-render'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
Expand All @@ -26,7 +27,19 @@ afterEach(cleanup)
describe('revealing a diff from a turn rollup', () => {
let restoreLayout = (): void => {}
beforeEach(() => {
restoreLayout = stubLayout()
restoreLayout = stubLayout({ scrollGeometry: true })
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement
) {
const row = this.closest<HTMLElement>('[data-index]')
const scroller = this.closest<HTMLElement>('[data-native-chat-scroll]')
return new DOMRect(
0,
row ? Number.parseFloat(row.style.top) - (scroller?.scrollTop ?? 0) : 0,
100,
this.offsetHeight
)
})
})
afterEach(() => {
restoreLayout()
Expand Down Expand Up @@ -79,29 +92,47 @@ describe('revealing a diff from a turn rollup', () => {
].map((item, index) => ({ ...item, sequence: index + 1 }))
const scrollTo = vi.fn()
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
const { container } = render(
const transcript = (): React.JSX.Element => (
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(withPrompts))}
journalItems={withPrompts}
isWorking={false}
expandSignal={false}
/>
)
const { container, rerender } = render(transcript())
const selectedTop = Number.parseFloat(
screen.getByText('Second prompt').closest<HTMLElement>('[data-index]')?.style.top ?? ''
)
expect(selectedTop).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
scrollTranscript(container, 6000)
expect(screen.getByText('Edited')).toBeInTheDocument()
scrollTo.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Your messages' }))
fireEvent.click(screen.getByRole('button', { name: 'Second prompt' }))
expect(scrollTo).toHaveBeenCalledTimes(1)
expect(scrollTo.mock.calls).toEqual([
[{ top: 6000, behavior: 'auto' }],
[{ top: selectedTop, behavior: 'smooth' }]
])
expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull()
scrollTo.mockClear()
rerender(transcript())
rerender(transcript())
expect(scrollTo).not.toHaveBeenCalled()

scrollTranscript(container, 0)
scrollTo.mockClear()
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
expect(scrollTo).toHaveBeenCalledTimes(1)
const diffTop = Number.parseFloat(
screen.getByText('Edited').closest<HTMLElement>('[data-index]')?.style.top ?? ''
)
expect(Number.isFinite(diffTop)).toBe(true)
expect(scrollTo.mock.calls.filter(([options]) => options?.behavior === 'smooth')).toEqual([
[{ top: diffTop, behavior: 'smooth' }]
])
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

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

import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, screen } from '@testing-library/react'
import { render } from './native-chat-app-root-test-render'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { NativeChatMessageList } from './NativeChatMessageList'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

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

import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, screen } from '@testing-library/react'
import { render } from './native-chat-app-root-test-render'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

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

import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, screen } from '@testing-library/react'
import { render } from './native-chat-app-root-test-render'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
Expand Down
Loading
Loading