Skip to content

Commit fd6f173

Browse files
committed
fix: guard queued prompt draining across modes
1 parent bf456ad commit fd6f173

11 files changed

Lines changed: 313 additions & 57 deletions

File tree

.changeset/calm-queues-compact.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@nanocollective/nanocoder": patch
3+
---
4+
5+
Resume queued prompts after slash commands and manual context compaction complete. Closes #1060.

.github/workflows/pr-labeler.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
owner,
3333
repo,
3434
path: '.github/labeler.yml',
35-
ref: context.payload.pull_request.base.sha,
35+
ref: context.payload.pull_request.head.sha,
3636
});
3737
if (data.type !== 'file' || typeof data.content !== 'string') {
3838
throw new Error('labeler.yml is not a file');

source/app/App.tsx

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ import {useUserMessageQueue} from '@/hooks/useUserMessageQueue';
4444
import {useVSCodeServer} from '@/hooks/useVSCodeServer';
4545
import {getAllSubagentProgress} from '@/services/subagent-events';
4646
import {generateKey} from '@/session/key-generator';
47-
import type {ImageAttachment} from '@/types/core';
4847
import type {ThemePreset} from '@/types/ui';
4948
import {createPinoLogger} from '@/utils/logging/pino-logger';
5049
import {setGlobalMessageQueue} from '@/utils/message-queue';
@@ -84,14 +83,6 @@ export default function App({
8483
// Use extracted hooks
8584
const appState = useAppState(initialDevelopmentMode);
8685
const userMessageQueue = useUserMessageQueue();
87-
const queuedUserSubmitRef = React.useRef<
88-
| ((
89-
message: string,
90-
displayValue: string,
91-
images?: ImageAttachment[],
92-
) => Promise<void>)
93-
| null
94-
>(null);
9586
const {exit} = useApp();
9687
const {isTrusted, handleConfirmTrust, isTrustLoading, isTrustedError} =
9788
useDirectoryTrust();
@@ -249,35 +240,6 @@ export default function App({
249240
}
250241
}, []);
251242

252-
const drainQueuedUserMessage = React.useCallback(() => {
253-
// Defer to a macrotask, not a microtask. `onConversationComplete` fires
254-
// deep inside the finishing turn's await chain, so a microtask drain would
255-
// start the next turn BEFORE that turn's `resetStreamingState()` finally
256-
// runs — and the stale reset would then wipe the new turn's abortController
257-
// and isGenerating, leaving the busy indicator (and Escape-to-cancel) dead.
258-
// A timeout runs after those continuations, so the drained turn keeps its
259-
// busy state.
260-
setTimeout(() => {
261-
void userMessageQueue.drainNextMessage(async message => {
262-
const submitQueuedMessage = queuedUserSubmitRef.current;
263-
if (!submitQueuedMessage || !appState.client || !appState.toolManager) {
264-
return false;
265-
}
266-
267-
await submitQueuedMessage(
268-
message.message,
269-
message.displayValue,
270-
message.images,
271-
);
272-
return true;
273-
});
274-
}, 0);
275-
}, [
276-
appState.client,
277-
appState.toolManager,
278-
userMessageQueue.drainNextMessage,
279-
]);
280-
281243
// Setup chat handler
282244
const chatHandler = useChatHandler({
283245
client: appState.client,
@@ -299,7 +261,6 @@ export default function App({
299261
appState.setCompactToolCounts(null);
300262
appState.compactToolCountsRef.current = {};
301263
appState.setLiveTaskList(null);
302-
drainQueuedUserMessage();
303264
},
304265
// A turn that started in plan mode finished uninterrupted — a plan was
305266
// produced. Flag it so the interactive UI can show the plan review bar.
@@ -578,10 +539,6 @@ export default function App({
578539
activeEditor: vscodeServer.activeEditor,
579540
});
580541

581-
React.useEffect(() => {
582-
queuedUserSubmitRef.current = handleUserSubmit;
583-
}, [handleUserSubmit]);
584-
585542
// Setup non-interactive mode
586543
const {nonInteractiveLoadingMessage} = useNonInteractiveMode({
587544
nonInteractivePrompt,

source/app/sections/interactive-app.spec.tsx

Lines changed: 173 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import test from 'ava';
22
import {Text} from 'ink';
33
import React from 'react';
4+
import {DELAY_COMMAND_COMPLETE_MS} from '@/constants';
5+
import {useUserMessageQueue} from '@/hooks/useUserMessageQueue';
46
import stripAnsi from 'strip-ansi';
57
import type {Message} from '@/types';
68
import {renderWithTheme} from '../../test-utils/render-with-theme.js';
@@ -43,6 +45,13 @@ interface Overrides {
4345
setPendingPlanProceed?: (v: string | null) => void;
4446
handleMessageSubmit?: (message: string) => Promise<void>;
4547
currentSessionId?: string | null;
48+
toolManager?: unknown;
49+
queuedMessages?: Array<{id: string; message: string; displayValue: string}>;
50+
handleUserSubmit?: (message: string) => Promise<void>;
51+
drainNextMessage?: (
52+
dispatch: (message: {id: string; message: string; displayValue: string}) =>
53+
boolean | Promise<boolean>,
54+
) => boolean | Promise<boolean>;
4655
}
4756

4857
function makeProps(o: Overrides = {}) {
@@ -51,6 +60,7 @@ function makeProps(o: Overrides = {}) {
5160

5261
const appState = {
5362
client: o.client ?? null,
63+
toolManager: o.toolManager ?? null,
5464
messages: o.messages ?? [],
5565
currentModel: 'mock-model',
5666
currentProvider: 'mock',
@@ -143,16 +153,16 @@ function makeProps(o: Overrides = {}) {
143153
pendingToolConfirmation: null,
144154
handleToolConfirmation: noop,
145155
handleQuestionAnswer: noop,
146-
handleUserSubmit: noopAsync,
156+
handleUserSubmit: o.handleUserSubmit ?? noopAsync,
147157
userMessageQueue: {
148-
queuedMessages: [],
158+
queuedMessages: o.queuedMessages ?? [],
149159
enqueueMessage: () => ({
150160
id: 'queued-test',
151161
message: '',
152162
displayValue: '',
153163
}),
154164
removeMessage: noop,
155-
drainNextMessage: () => false,
165+
drainNextMessage: o.drainNextMessage ?? (() => false),
156166
},
157167
handleIdeSelect: noop,
158168
} as never;
@@ -163,6 +173,166 @@ test('renders without crashing in default state', t => {
163173
t.truthy(lastFrame());
164174
});
165175

176+
test('does not drain queued prompts while a turn is generating', async t => {
177+
let submitted = false;
178+
const {unmount} = renderWithTheme(
179+
<InteractiveApp
180+
{...makeProps({
181+
startChat: true,
182+
client: {},
183+
toolManager: {},
184+
isGenerating: true,
185+
isConversationComplete: true,
186+
queuedMessages: [
187+
{id: 'queued-1', message: 'queued prompt', displayValue: 'queued prompt'},
188+
],
189+
handleUserSubmit: async () => {
190+
submitted = true;
191+
},
192+
})}
193+
/>,
194+
);
195+
196+
await new Promise(resolve => setTimeout(resolve, 25));
197+
t.false(submitted);
198+
unmount();
199+
});
200+
201+
test('does not drain queued prompts while a modal mode is active', async t => {
202+
let submitted = false;
203+
const {unmount} = renderWithTheme(
204+
<InteractiveApp
205+
{...makeProps({
206+
startChat: true,
207+
client: {},
208+
toolManager: {},
209+
activeMode: 'model',
210+
isConversationComplete: true,
211+
queuedMessages: [
212+
{id: 'queued-1', message: 'queued prompt', displayValue: 'queued prompt'},
213+
],
214+
handleUserSubmit: async () => {
215+
submitted = true;
216+
},
217+
})}
218+
/>,
219+
);
220+
221+
await new Promise(resolve => setTimeout(resolve, 25));
222+
t.false(submitted);
223+
unmount();
224+
});
225+
226+
test('does not drain queued prompts while plan review is active', async t => {
227+
let submitted = false;
228+
const {unmount} = renderWithTheme(
229+
<InteractiveApp
230+
{...makeProps({
231+
startChat: true,
232+
client: {},
233+
toolManager: {},
234+
planReviewState: {show: true, originalMessage: 'make a plan'},
235+
isConversationComplete: true,
236+
queuedMessages: [
237+
{id: 'queued-1', message: 'queued prompt', displayValue: 'queued prompt'},
238+
],
239+
handleUserSubmit: async () => {
240+
submitted = true;
241+
},
242+
})}
243+
/>,
244+
);
245+
246+
await new Promise(resolve => setTimeout(resolve, 25));
247+
t.false(submitted);
248+
unmount();
249+
});
250+
251+
test('drains every queued prompt after each dispatched turn returns to idle', async t => {
252+
const submitted: string[] = [];
253+
254+
const QueueDrainHarness = () => {
255+
const userMessageQueue = useUserMessageQueue();
256+
const [isConversationComplete, setIsConversationComplete] =
257+
React.useState(true);
258+
259+
React.useEffect(() => {
260+
userMessageQueue.enqueueMessage({message: 'first', displayValue: 'first'});
261+
userMessageQueue.enqueueMessage({message: 'second', displayValue: 'second'});
262+
}, [userMessageQueue.enqueueMessage]);
263+
264+
return (
265+
<InteractiveApp
266+
{...makeProps({
267+
startChat: true,
268+
client: {},
269+
toolManager: {},
270+
isConversationComplete,
271+
handleUserSubmit: async message => {
272+
submitted.push(message);
273+
setIsConversationComplete(false);
274+
await new Promise(resolve => setTimeout(resolve, 10));
275+
setIsConversationComplete(true);
276+
},
277+
})}
278+
userMessageQueue={userMessageQueue}
279+
/>
280+
);
281+
};
282+
283+
const {unmount} = renderWithTheme(<QueueDrainHarness />);
284+
await new Promise(resolve => setTimeout(resolve, 100));
285+
t.deepEqual(submitted, ['first', 'second']);
286+
unmount();
287+
});
288+
289+
test('drains a prompt after delayed command completion when the app is idle', async t => {
290+
const submitted: string[] = [];
291+
292+
const DelayedCommandHarness = () => {
293+
const userMessageQueue = useUserMessageQueue();
294+
const [isToolExecuting, setIsToolExecuting] = React.useState(true);
295+
const [isConversationComplete, setIsConversationComplete] =
296+
React.useState(false);
297+
298+
React.useEffect(() => {
299+
userMessageQueue.enqueueMessage({
300+
message: 'after compact',
301+
displayValue: 'after compact',
302+
});
303+
const timeout = setTimeout(() => {
304+
setIsToolExecuting(false);
305+
setIsConversationComplete(true);
306+
}, DELAY_COMMAND_COMPLETE_MS);
307+
308+
return () => clearTimeout(timeout);
309+
}, [userMessageQueue.enqueueMessage]);
310+
311+
return (
312+
<InteractiveApp
313+
{...makeProps({
314+
startChat: true,
315+
client: {},
316+
toolManager: {},
317+
isToolExecuting,
318+
isConversationComplete,
319+
handleUserSubmit: async message => {
320+
submitted.push(message);
321+
},
322+
})}
323+
userMessageQueue={userMessageQueue}
324+
/>
325+
);
326+
};
327+
328+
const {unmount} = renderWithTheme(<DelayedCommandHarness />);
329+
await new Promise(resolve =>
330+
setTimeout(resolve, DELAY_COMMAND_COMPLETE_MS + 40),
331+
);
332+
t.deepEqual(submitted, ['after compact']);
333+
unmount();
334+
});
335+
166336
test('renders the static-component marker through ChatHistory', t => {
167337
const {lastFrame} = renderWithTheme(
168338
<InteractiveApp {...makeProps({startChat: true})} />,

0 commit comments

Comments
 (0)