Skip to content

Commit 92a2dd0

Browse files
fix(desktop): stop approval mascot popup loop
Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
1 parent a0accfb commit 92a2dd0

5 files changed

Lines changed: 207 additions & 5 deletions

File tree

desktop/src/renderer/components/conversation/useComposerMascot.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,16 @@ export function useComposerMascot({
9292
if (threadChanged) {
9393
if (lastTurnId) handledRef.current = `${lastTurnId}:${lastTurnStatus}`
9494
dismissedApprovalRef.current = null
95-
setLocal(turnStatus === 'waitingApproval' ? { kind: 'approval' } : null)
95+
setLocal(turnStatus === 'waitingApproval' && approvalItemId ? { kind: 'approval' } : null)
9696
return
9797
}
9898

9999
if (turnStatus === 'waitingApproval') {
100-
if (approvalItemId && dismissedApprovalRef.current === approvalItemId) return
100+
if (!approvalItemId) {
101+
setLocal((prev) => (prev?.kind === 'approval' ? null : prev))
102+
return
103+
}
104+
if (dismissedApprovalRef.current === approvalItemId) return
101105
setLocal((prev) => (prev?.kind === 'approval' ? prev : { kind: 'approval' }))
102106
return
103107
}

desktop/src/renderer/stores/conversationStore.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1941,6 +1941,11 @@ export const useConversationStore = create<ConversationStore>((set, get) => ({
19411941
: 'idle'
19421942
const compactedNotice = latestCompactedNotice(rehydratedTurns)
19431943
set((state) => {
1944+
// A stale thread/read can still report waitingApproval after the local decision
1945+
// has already cleared its request.
1946+
const restoredTurnStatus = activeTurnStatus === 'waitingApproval' && state.pendingApproval == null
1947+
? 'running'
1948+
: activeTurnStatus
19441949
const terminalApplied = applyPendingTerminalsToTurns(
19451950
turnsForState,
19461951
state.pendingTerminalByCallId
@@ -1951,7 +1956,7 @@ export const useConversationStore = create<ConversationStore>((set, get) => ({
19511956
)
19521957
return {
19531958
turns: toolCompletionApplied.turns,
1954-
turnStatus: activeTurnStatus,
1959+
turnStatus: restoredTurnStatus,
19551960
activeTurnId: activeTurn ? activeTurn.id : null,
19561961
interruptingTurnId: null,
19571962
streamingMessage: preserveEmptyRealtimeSnapshot ? state.streamingMessage : '',

desktop/src/renderer/tests/ApprovalDecisionComposer.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,43 @@ describe('ApprovalDecisionComposer', () => {
146146
expect(onResponseAccepted).toHaveBeenCalledTimes(1)
147147
})
148148

149+
it('does not re-enter waitingApproval when a stale snapshot arrives after submission', async () => {
150+
const pending = pendingApproval()
151+
useConversationStore.setState({
152+
turns: [{
153+
id: 'turn-approval',
154+
threadId: 'thread-approval',
155+
status: 'running',
156+
items: [],
157+
startedAt: new Date().toISOString()
158+
}],
159+
activeTurnId: 'turn-approval'
160+
})
161+
setPendingApproval(pending)
162+
renderWithLocale(<ApprovalDecisionComposer request={pending} />)
163+
164+
fireEvent.keyDown(window, { key: 'Enter' })
165+
await waitFor(() => {
166+
expect(useConversationStore.getState().pendingApproval).toBeNull()
167+
})
168+
169+
act(() => {
170+
useConversationStore.getState().setTurns([{
171+
id: 'turn-approval',
172+
threadId: 'thread-approval',
173+
status: 'waitingApproval',
174+
items: [],
175+
startedAt: new Date().toISOString()
176+
}], {
177+
preserveExistingRealtime: true,
178+
realtimeScopeThreadId: 'thread-approval'
179+
})
180+
})
181+
182+
expect(useConversationStore.getState().turnStatus).toBe('running')
183+
expect(useConversationStore.getState().pendingApproval).toBeNull()
184+
})
185+
149186
it('uses number keys and Arrow keys to submit the selected decision', async () => {
150187
const pending = pendingApproval()
151188
setPendingApproval(pending)

desktop/src/renderer/tests/conversationStore.test.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,47 @@ describe('turn lifecycle', () => {
12181218
expect(state.activeTurnId).toBeNull()
12191219
})
12201220

1221+
it('does not resurrect waitingApproval from a stale snapshot after its request was resolved', () => {
1222+
s().onTurnStarted(makeTurn())
1223+
s().onApprovalRequest('bridge-stale-approval', {
1224+
threadId: 'thread-1',
1225+
turnId: 'turn-1',
1226+
requestId: 'request-stale-approval',
1227+
itemId: 'approval-stale-approval',
1228+
approvalType: 'shell',
1229+
operation: 'npm test'
1230+
})
1231+
s().onApprovalDecision('accept')
1232+
1233+
s().setTurns([makeTurn({ status: 'waitingApproval' })], {
1234+
preserveExistingRealtime: true,
1235+
realtimeScopeThreadId: 'thread-1'
1236+
})
1237+
1238+
expect(s().pendingApproval).toBeNull()
1239+
expect(s().turnStatus).toBe('running')
1240+
})
1241+
1242+
it('restores waitingApproval when the actionable request is still present', () => {
1243+
s().onTurnStarted(makeTurn())
1244+
s().onApprovalRequest('bridge-live-approval', {
1245+
threadId: 'thread-1',
1246+
turnId: 'turn-1',
1247+
requestId: 'request-live-approval',
1248+
itemId: 'approval-live-approval',
1249+
approvalType: 'shell',
1250+
operation: 'npm test'
1251+
})
1252+
1253+
s().setTurns([makeTurn({ status: 'waitingApproval' })], {
1254+
preserveExistingRealtime: true,
1255+
realtimeScopeThreadId: 'thread-1'
1256+
})
1257+
1258+
expect(s().pendingApproval?.requestId).toBe('request-live-approval')
1259+
expect(s().turnStatus).toBe('waitingApproval')
1260+
})
1261+
12211262
it('does not preserve terminal history over a same-thread empty snapshot', () => {
12221263
s().setTurns([
12231264
makeTurn({
@@ -2248,12 +2289,12 @@ describe('setTurns', () => {
22482289
expect(s().turnStartedAt).not.toBeNull()
22492290
})
22502291

2251-
it('restores waitingApproval as the active turn state', () => {
2292+
it('keeps a waitingApproval turn active but runnable until its request is replayed', () => {
22522293
s().setTurns([
22532294
makeTurn({ id: 'turn-wait-approval', status: 'waitingApproval', items: [] })
22542295
])
22552296

2256-
expect(s().turnStatus).toBe('waitingApproval')
2297+
expect(s().turnStatus).toBe('running')
22572298
expect(s().activeTurnId).toBe('turn-wait-approval')
22582299
expect(s().turnStartedAt).not.toBeNull()
22592300
})
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
3+
import { LocaleProvider } from '../contexts/LocaleContext'
4+
import { useComposerMascot } from '../components/conversation/useComposerMascot'
5+
import { useConversationStore, type PendingApproval } from '../stores/conversationStore'
6+
import { installDesktopApiMock } from './desktopApiMock'
7+
8+
const THREAD_ID = 'thread-mascot'
9+
10+
function approval(overrides: Partial<PendingApproval> = {}): PendingApproval {
11+
return {
12+
bridgeId: 'bridge-mascot-1',
13+
threadId: THREAD_ID,
14+
turnId: 'turn-mascot',
15+
requestId: 'request-mascot-1',
16+
locallySubmittedDecision: null,
17+
itemId: 'approval-mascot-1',
18+
approvalType: 'shell',
19+
operation: 'npm test',
20+
target: '<workspace>',
21+
reason: 'Run the test suite.',
22+
...overrides
23+
}
24+
}
25+
26+
function MascotHarness(): JSX.Element {
27+
const interaction = useComposerMascot({ threadId: THREAD_ID, workspacePath: '<workspace>' })
28+
const bubble = interaction?.bubble
29+
return (
30+
<div>
31+
{bubble && (
32+
<section aria-label="mascot bubble">
33+
<h1>{bubble.title}</h1>
34+
{bubble.body && <p>{bubble.body}</p>}
35+
{bubble.actions?.map((action) => (
36+
<button key={action.label} type="button" onClick={action.onClick}>
37+
{action.label}
38+
</button>
39+
))}
40+
</section>
41+
)}
42+
</div>
43+
)
44+
}
45+
46+
function renderHarness(): void {
47+
render(
48+
<LocaleProvider>
49+
<MascotHarness />
50+
</LocaleProvider>
51+
)
52+
}
53+
54+
describe('useComposerMascot approval nudge', () => {
55+
beforeEach(() => {
56+
vi.clearAllMocks()
57+
installDesktopApiMock({
58+
settings: { get: vi.fn().mockResolvedValue({ locale: 'en' }) }
59+
})
60+
useConversationStore.getState().reset()
61+
})
62+
63+
it('does not show an approval bubble for a waiting status without an actionable request', () => {
64+
useConversationStore.setState({
65+
turnStatus: 'waitingApproval',
66+
pendingApproval: null,
67+
pendingApprovals: []
68+
})
69+
70+
renderHarness()
71+
72+
expect(screen.queryByRole('heading', { name: 'Your approval is needed' })).not.toBeInTheDocument()
73+
})
74+
75+
it('keeps the same approval dismissed across rerenders and replay, but shows a new request', async () => {
76+
const first = approval()
77+
useConversationStore.setState({
78+
turnStatus: 'waitingApproval',
79+
pendingApproval: first,
80+
pendingApprovals: [first]
81+
})
82+
83+
renderHarness()
84+
85+
expect(await screen.findByRole('heading', { name: 'Your approval is needed' })).toBeInTheDocument()
86+
fireEvent.click(screen.getByRole('button', { name: 'Got it' }))
87+
await waitFor(() => {
88+
expect(screen.queryByRole('heading', { name: 'Your approval is needed' })).not.toBeInTheDocument()
89+
})
90+
91+
const replayed = { ...first, reason: 'Same request replayed with updated details.' }
92+
act(() => {
93+
useConversationStore.setState({
94+
pendingApproval: replayed,
95+
pendingApprovals: [replayed]
96+
})
97+
})
98+
expect(screen.queryByRole('heading', { name: 'Your approval is needed' })).not.toBeInTheDocument()
99+
100+
const second = approval({
101+
bridgeId: 'bridge-mascot-2',
102+
requestId: 'request-mascot-2',
103+
itemId: 'approval-mascot-2',
104+
reason: 'Run the next command.'
105+
})
106+
act(() => {
107+
useConversationStore.setState({
108+
pendingApproval: second,
109+
pendingApprovals: [second]
110+
})
111+
})
112+
113+
expect(await screen.findByRole('heading', { name: 'Your approval is needed' })).toBeInTheDocument()
114+
})
115+
})

0 commit comments

Comments
 (0)