fix(desktop): scope approval hints and omit zero message counts
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user