fix(desktop): scope approval hints and omit zero message counts

This commit is contained in:
brooklyn!
2026-09-11 05:34:39 -07:00
parent b08a26791f
commit dee30d123d
4 changed files with 71 additions and 14 deletions

View File

@@ -1,7 +1,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { clearAllPrompts, clearApprovalRequest, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import {
onScrollToBottomRequest,
@@ -45,7 +45,7 @@ describe('ScrollToBottomButton', () => {
it('morphs into the approval pill when scrolled up with a pending approval', () => {
pendingApproval()
setThreadAtBottom(false)
render(<ScrollToBottomButton sessionId={null} />)
render(<ScrollToBottomButton sessionId="sess-1" />)
expect(screen.getByRole('button', { name: 'Approval needed' })).toBeTruthy()
expect(screen.getByText('Approval needed')).toBeTruthy()
@@ -59,6 +59,22 @@ describe('ScrollToBottomButton', () => {
expect(screen.queryByRole('button')).toBeNull()
})
it('labels uncounted content without zero and follows only its own approval', () => {
pendingApproval()
setThreadAtBottom(false)
const view = render(<ScrollToBottomButton sessionId="tile-runtime" />)
expect(screen.getByRole('button', { name: 'Scroll to bottom' }).textContent).toBe('Scroll to bottom')
act(() => setApprovalRequest({ command: 'x', description: 'd', sessionId: 'tile-runtime', requestId: 'r1' }))
expect(screen.getByRole('button', { name: 'Approval needed' })).toBeTruthy()
act(() => clearApprovalRequest('tile-runtime', 'r1'))
expect(screen.queryByText('Approval needed')).toBeNull()
expect(screen.getByRole('button').textContent).toBe('Scroll to bottom')
view.rerender(<ScrollToBottomButton sessionId="sess-1" />)
expect(screen.getByRole('button', { name: 'Approval needed' })).toBeTruthy()
})
it('re-arms sticky-bottom on click', () => {
const handler = vi.fn()
const stop = onScrollToBottomRequest(handler, 'sess-1')

View File

@@ -1,13 +1,13 @@
import { useStore } from '@nanostores/react'
import { useReducedMotion } from 'motion/react'
import { useRef } from 'react'
import { useMemo, useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { AnimatedInt } from '@/components/ui/diff-count'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $approvalRequest } from '@/store/prompts'
import { sessionApprovalRequest } from '@/store/prompts'
import { $threadJumpButtonVisible, $threadMessagesBelow, requestScrollToBottom } from '@/store/thread-scroll'
/**
@@ -36,11 +36,11 @@ export function ScrollToBottomButton({ sessionId }: { sessionId: string | null }
const visible = useStore($threadJumpButtonVisible)
const count = useStore($threadMessagesBelow)
const reducedMotion = useReducedMotion()
const request = useStore($approvalRequest)
const request = useStore(useMemo(() => sessionApprovalRequest(sessionId), [sessionId]))
// Scrolled away while an approval is pending → the inline Run/Reject bar is
// below the fold. Relabel so the user knows the session needs them, not just
// that there's more to read.
const approval = visible && Boolean(request)
const visibleApproval = Boolean(request)
const hasShownRef = useRef(false)
if (visible) {
@@ -48,10 +48,14 @@ export function ScrollToBottomButton({ sessionId }: { sessionId: string | null }
}
const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle'
const countLabel = t.sidebar.messageCount(count)
const [beforeCount, afterCount] = countLabel.split(String(count))
const countLabel = count > 0 ? t.sidebar.messageCount(count) : ''
const [beforeCount, afterCount] = countLabel ? countLabel.split(String(count)) : ['', '']
const label = approval ? t.assistant.approval.jumpToApproval : `${t.assistant.thread.scrollToBottom} · ${countLabel}`
const label = visibleApproval
? t.assistant.approval.jumpToApproval
: countLabel
? `${t.assistant.thread.scrollToBottom} · ${countLabel}`
: t.assistant.thread.scrollToBottom
return (
<button
@@ -59,7 +63,7 @@ export function ScrollToBottomButton({ sessionId }: { sessionId: string | null }
aria-label={label}
className={cn(
'thread-jump-button absolute left-1/2 z-20 flex h-8 items-center gap-1.5 rounded-full border bg-(--composer-fill) px-3 text-xs font-medium backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
approval
visibleApproval
? 'border-primary/40 text-primary hover:bg-primary/10'
: 'border-border/65 text-muted-foreground hover:text-foreground',
!visible && 'pointer-events-none'
@@ -76,7 +80,7 @@ export function ScrollToBottomButton({ sessionId }: { sessionId: string | null }
type="button"
>
<Codicon name="arrow-down" size="0.875rem" />
{approval ? (
{visibleApproval || count <= 0 ? (
<span>{label}</span>
) : (
<span aria-hidden className="whitespace-nowrap tabular-nums">

View File

@@ -133,6 +133,31 @@ describe('approval prompt store', () => {
])
})
it('clears an absent approval without overwriting a newer live request', async () => {
const old = { command: 'x', description: 'd', requestId: 'old', sessionId: 's1' }
setApprovalRequest(old)
await replayPendingApproval({ request: async () => ({ approvals: [] }) }, 's1')
expect($approvalRequest.get()).toBeNull()
setApprovalRequest(old)
let resolve!: (value: unknown) => void
const pending = replayPendingApproval(
{
request: () =>
new Promise(done => {
resolve = done
})
},
's1'
)
setApprovalRequest({ ...old, requestId: 'new' })
resolve({ approvals: [] })
await pending
expect($approvalRequest.get()?.requestId).toBe('new')
})
it('does not replay a pending approval after the runtime is rejected as gone', async () => {
const request = vi.fn(async () => {
throw new JsonRpcGatewayError('session not found', { code: 4001 })

View File

@@ -173,6 +173,7 @@ export async function replayPendingApproval(gateway: ApprovalGateway | null, ses
return
}
const previous = approval.$all.get()[keyFor(sessionId)]
let rawResult: unknown
try {
@@ -192,9 +193,20 @@ export async function replayPendingApproval(gateway: ApprovalGateway | null, ses
const result =
rawResult && typeof rawResult === 'object' ? (rawResult as { approvals?: PendingApprovalPayload[] }) : {}
const pending = Array.isArray(result?.approvals) ? result.approvals[0] : undefined
// Live requests/responses outrank a replay that was already in flight.
if (approval.$all.get()[keyFor(sessionId)] !== previous || !Array.isArray(result.approvals)) {
return
}
if (!pending || typeof pending.request_id !== 'string') {
const pending = result.approvals[0]
if (!pending) {
clearApprovalRequest(sessionId, previous?.requestId)
return
}
if (typeof pending.request_id !== 'string') {
return
}