mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
Promote queued Assistant follow-ups
This commit is contained in:
@@ -476,6 +476,36 @@ describe('AIChatAPI', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('runs the queue-hold dev stream fixture without opening a provider request', async () => {
|
||||
const onEvent = vi.fn();
|
||||
|
||||
await AIChatAPI.chat('/fixture queue-hold', undefined, 'openrouter:qwen/qwen3.7-plus', onEvent);
|
||||
|
||||
expect(apiFetchMock).not.toHaveBeenCalled();
|
||||
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual([
|
||||
'session',
|
||||
'workflow_state',
|
||||
'workflow_state',
|
||||
'workflow_state',
|
||||
'content',
|
||||
'done',
|
||||
]);
|
||||
expect(onEvent.mock.calls[3][0]).toMatchObject({
|
||||
type: 'workflow_state',
|
||||
data: {
|
||||
phase: 'stream_idle',
|
||||
message: 'Holding the Assistant turn open so queued follow-ups can be reordered.',
|
||||
},
|
||||
});
|
||||
expect(onEvent.mock.calls[5][0]).toMatchObject({
|
||||
type: 'done',
|
||||
data: {
|
||||
session_id: 'dev-fixture-queue-hold',
|
||||
model: 'openrouter:qwen/qwen3.7-plus',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('runs the compacted-artifact dev stream fixture without opening a provider request', async () => {
|
||||
const onEvent = vi.fn();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const AI_CHAT_DEV_STREAM_FIXTURE_PROMPTS = [
|
||||
'/fixture pending-tool',
|
||||
'/fixture provider-retry',
|
||||
'/fixture stream-idle',
|
||||
'/fixture queue-hold',
|
||||
'/fixture compacted-artifact',
|
||||
'/fixture skipped-tool',
|
||||
] as const;
|
||||
@@ -537,6 +538,51 @@ const buildStreamIdleFixtureEvents = (model?: string): AIChatStreamEvent[] => [
|
||||
},
|
||||
];
|
||||
|
||||
const buildQueueHoldFixtureEvents = (model?: string): AIChatStreamEvent[] => [
|
||||
{
|
||||
type: 'session',
|
||||
data: { id: 'dev-fixture-queue-hold' },
|
||||
},
|
||||
{
|
||||
type: 'workflow_state',
|
||||
data: {
|
||||
phase: 'request_start',
|
||||
message: 'Preparing Pulse context.',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'workflow_state',
|
||||
data: {
|
||||
phase: 'provider_start',
|
||||
message: 'Sent request to OpenRouter; waiting for the first token.',
|
||||
provider: 'openrouter',
|
||||
model: assistantFixtureModel(model),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'workflow_state',
|
||||
data: {
|
||||
phase: 'stream_idle',
|
||||
message: 'Holding the Assistant turn open so queued follow-ups can be reordered.',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'content',
|
||||
data: {
|
||||
text: 'The queue-hold fixture kept the turn active long enough to inspect queued follow-up controls.',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'done',
|
||||
data: {
|
||||
session_id: 'dev-fixture-queue-hold',
|
||||
model: assistantFixtureModel(model),
|
||||
input_tokens: 44,
|
||||
output_tokens: 23,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const buildCompactedArtifactFixtureEvents = (model?: string): AIChatStreamEvent[] => [
|
||||
{
|
||||
type: 'session',
|
||||
@@ -619,6 +665,9 @@ const buildFixtureEvents = (prompt: string, model?: string): AIChatStreamEvent[]
|
||||
if (normalized === '/fixture stream-idle') {
|
||||
return buildStreamIdleFixtureEvents(model);
|
||||
}
|
||||
if (normalized === '/fixture queue-hold') {
|
||||
return buildQueueHoldFixtureEvents(model);
|
||||
}
|
||||
return buildDeviceCountFixtureEvents(model);
|
||||
};
|
||||
|
||||
@@ -651,6 +700,13 @@ const fixtureStepDelay = (
|
||||
) {
|
||||
return 1800;
|
||||
}
|
||||
if (
|
||||
normalizedPrompt === '/fixture queue-hold' &&
|
||||
event.type === 'workflow_state' &&
|
||||
event.data.phase === 'stream_idle'
|
||||
) {
|
||||
return 10000;
|
||||
}
|
||||
if (normalizedPrompt === '/fixture pending-tool') return defaultDelayMs;
|
||||
if (normalizedPrompt !== '/fixture tool-burst') return defaultDelayMs;
|
||||
if (event.type === 'tool_start') return 0;
|
||||
|
||||
@@ -51,6 +51,7 @@ const {
|
||||
stop: vi.fn(),
|
||||
cancelQueuedFollowUp: vi.fn(),
|
||||
takeQueuedFollowUp: vi.fn((): QueuedFollowUp | undefined => undefined),
|
||||
sendQueuedFollowUpNow: vi.fn().mockResolvedValue(true),
|
||||
clearQueuedFollowUps: vi.fn(),
|
||||
clearMessages: vi.fn(),
|
||||
loadSession: vi.fn().mockResolvedValue(true),
|
||||
@@ -427,6 +428,7 @@ beforeEach(() => {
|
||||
mockChat.queuedFollowUps.mockReturnValue([]);
|
||||
mockChat.queuedFollowUpCount.mockReturnValue(0);
|
||||
mockChat.sendMessage.mockResolvedValue(true);
|
||||
mockChat.sendQueuedFollowUpNow.mockResolvedValue(true);
|
||||
mockChat.undoLastTurn.mockResolvedValue(null);
|
||||
mockChat.redoLastTurn.mockResolvedValue({ success: false, canRedo: false });
|
||||
mockChat.takeQueuedFollowUp.mockReturnValue(undefined);
|
||||
@@ -2388,6 +2390,40 @@ describe('AIChat', () => {
|
||||
expect(mockChat.clearQueuedFollowUps).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lets a later queued follow-up be promoted to send next', async () => {
|
||||
mockChat.queuedFollowUpCount.mockReturnValue(2);
|
||||
mockChat.queuedFollowUps.mockReturnValue([
|
||||
{
|
||||
id: 'queued-1',
|
||||
messageId: 'msg-queued-1',
|
||||
prompt: 'first queued prompt',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'queued-2',
|
||||
messageId: 'msg-queued-2',
|
||||
prompt: 'second queued prompt',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
renderChat();
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'Send queued follow-up next: first queued prompt',
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Send queued follow-up next: second queued prompt',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockChat.sendQueuedFollowUpNow).toHaveBeenCalledWith('queued-2');
|
||||
await waitFor(() => expect(document.activeElement).toHaveAttribute('placeholder'));
|
||||
});
|
||||
|
||||
it('passes queued follow-up metadata and row actions into the transcript', () => {
|
||||
const queuedFollowUps: QueuedFollowUp[] = [
|
||||
{
|
||||
|
||||
@@ -104,6 +104,7 @@ describe('useChat', () => {
|
||||
expect(typeof chat.stop).toBe('function');
|
||||
expect(typeof chat.cancelQueuedFollowUp).toBe('function');
|
||||
expect(typeof chat.takeQueuedFollowUp).toBe('function');
|
||||
expect(typeof chat.sendQueuedFollowUpNow).toBe('function');
|
||||
expect(typeof chat.clearQueuedFollowUps).toBe('function');
|
||||
expect(typeof chat.clearMessages).toBe('function');
|
||||
expect(typeof chat.loadSession).toBe('function');
|
||||
@@ -749,6 +750,54 @@ describe('useChat', () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('promotes a queued follow-up to send next while the active response is streaming', async () => {
|
||||
const resolvers: Array<() => void> = [];
|
||||
mockChat.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
}),
|
||||
);
|
||||
|
||||
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess' }));
|
||||
const first = chat.sendMessage('first');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
await chat.sendMessage('second');
|
||||
await chat.sendMessage('third');
|
||||
|
||||
const third = chat.queuedFollowUps().find((entry) => entry.prompt === 'third');
|
||||
expect(third).toBeDefined();
|
||||
await expect(chat.sendQueuedFollowUpNow(third!.id)).resolves.toBe(true);
|
||||
|
||||
expect(chat.queuedFollowUps().map((entry) => entry.prompt)).toEqual(['third', 'second']);
|
||||
expect(
|
||||
chat
|
||||
.messages()
|
||||
.filter((message) => message.role === 'user' && message.delivery === 'queued')
|
||||
.map((message) => message.content),
|
||||
).toEqual(['third', 'second']);
|
||||
|
||||
resolvers[0]();
|
||||
await first;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(mockChat).toHaveBeenCalledTimes(2);
|
||||
expect(mockChat.mock.calls[1][0]).toBe('third');
|
||||
expect(chat.queuedFollowUps().map((entry) => entry.prompt)).toEqual(['second']);
|
||||
|
||||
resolvers[1]();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(mockChat).toHaveBeenCalledTimes(3);
|
||||
expect(mockChat.mock.calls[2][0]).toBe('second');
|
||||
|
||||
resolvers[2]();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(chat.isLoading()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('cancels a queued follow-up before it is sent', async () => {
|
||||
let resolveFirst!: () => void;
|
||||
mockChat.mockImplementation(
|
||||
|
||||
@@ -275,6 +275,40 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
return item;
|
||||
};
|
||||
|
||||
const moveQueuedMessageToFront = (messageId: string) => {
|
||||
setMessages((prev) => {
|
||||
const targetIndex = prev.findIndex(
|
||||
(msg) => msg.id === messageId && msg.role === 'user' && msg.delivery === 'queued',
|
||||
);
|
||||
if (targetIndex < 0) return prev;
|
||||
|
||||
const target = prev[targetIndex];
|
||||
const withoutTarget = prev.filter((msg) => msg.id !== messageId);
|
||||
const firstQueuedIndex = withoutTarget.findIndex(
|
||||
(msg) => msg.role === 'user' && msg.delivery === 'queued',
|
||||
);
|
||||
if (firstQueuedIndex < 0) return prev;
|
||||
return [
|
||||
...withoutTarget.slice(0, firstQueuedIndex),
|
||||
target,
|
||||
...withoutTarget.slice(firstQueuedIndex),
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const promoteQueuedFollowUp = (id: string): boolean => {
|
||||
const item = queuedFollowUps().find((entry) => entry.id === id);
|
||||
if (!item) return false;
|
||||
|
||||
setQueuedFollowUps((prev) => {
|
||||
const currentIndex = prev.findIndex((entry) => entry.id === id);
|
||||
if (currentIndex <= 0) return prev;
|
||||
return [prev[currentIndex], ...prev.slice(0, currentIndex), ...prev.slice(currentIndex + 1)];
|
||||
});
|
||||
moveQueuedMessageToFront(item.messageId);
|
||||
return true;
|
||||
};
|
||||
|
||||
const clearQueuedFollowUps = () => {
|
||||
const messageIds = new Set(queuedFollowUps().map((entry) => entry.messageId));
|
||||
setQueuedFollowUps([]);
|
||||
@@ -730,12 +764,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
return {
|
||||
...msg,
|
||||
streamEvents: resolvedTool
|
||||
? replacePendingToolStreamEvents(
|
||||
msg.streamEvents || [],
|
||||
resolvedTool,
|
||||
matchesTool,
|
||||
now,
|
||||
)
|
||||
? replacePendingToolStreamEvents(msg.streamEvents || [], resolvedTool, matchesTool, now)
|
||||
: msg.streamEvents,
|
||||
workflowStatus: undefined,
|
||||
workflowStatusHistory: undefined,
|
||||
@@ -1861,6 +1890,19 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
return startMessageSend(prompt, mentions, findingId, sendOptions);
|
||||
};
|
||||
|
||||
const sendQueuedFollowUpNow = async (id: string): Promise<boolean> => {
|
||||
const item = queuedFollowUps().find((entry) => entry.id === id);
|
||||
if (!item) return false;
|
||||
if (isLoading()) {
|
||||
return promoteQueuedFollowUp(id);
|
||||
}
|
||||
|
||||
setQueuedFollowUps((prev) => prev.filter((entry) => entry.id !== id));
|
||||
return startMessageSend(item.prompt, item.mentions, item.findingId, item.sendOptions, {
|
||||
queuedMessageId: item.messageId,
|
||||
});
|
||||
};
|
||||
|
||||
// Clear messages and reset session (for starting fresh)
|
||||
const clearMessages = () => {
|
||||
void cancelActiveRequest();
|
||||
@@ -2179,6 +2221,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
queuedFollowUps,
|
||||
queuedFollowUpCount: () => queuedFollowUps().length,
|
||||
sendMessage,
|
||||
sendQueuedFollowUpNow,
|
||||
retryMessage,
|
||||
undoLastTurn,
|
||||
redoLastTurn,
|
||||
|
||||
@@ -1585,6 +1585,12 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
queueMicrotask(resizeTextarea);
|
||||
};
|
||||
|
||||
const sendQueuedFollowUpNext = (id: string) => {
|
||||
void chat.sendQueuedFollowUpNow(id).finally(() => {
|
||||
focusComposer();
|
||||
});
|
||||
};
|
||||
|
||||
const restoreLastTurnDraft = (draft: RestoredPromptDraft) => {
|
||||
resetPromptHistoryNavigation();
|
||||
setEditingQueuedFollowUp(null);
|
||||
@@ -4349,11 +4355,22 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
</div>
|
||||
<div class="mt-1 max-h-24 space-y-1 overflow-y-auto">
|
||||
<For each={chat.queuedFollowUps()}>
|
||||
{(queued) => {
|
||||
{(queued, index) => {
|
||||
const preview = () => queuedFollowUpPreview(queued.prompt);
|
||||
return (
|
||||
<div class="flex min-h-7 items-center gap-2 rounded-md bg-white/70 px-2 py-1 text-xs text-blue-900 dark:bg-blue-900/30 dark:text-blue-100">
|
||||
<span class="min-w-0 flex-1 truncate">{preview()}</span>
|
||||
<Show when={chat.queuedFollowUpCount() > 1 && index() > 0}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendQueuedFollowUpNext(queued.id)}
|
||||
class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 dark:text-blue-200 dark:hover:bg-blue-900/60"
|
||||
title="Send queued follow-up next"
|
||||
aria-label={`Send queued follow-up next: ${preview()}`}
|
||||
>
|
||||
<SendIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editQueuedFollowUp(queued.id)}
|
||||
|
||||
Reference in New Issue
Block a user