Show Assistant queued follow-up row controls

This commit is contained in:
rcourtman
2026-06-06 04:21:03 +01:00
parent b19140d8b5
commit 2f19f13c4c
7 changed files with 218 additions and 20 deletions
@@ -236,6 +236,17 @@ runtime cost control, and shared AI transport surfaces.
still have a valid provider/model shape; malformed route strings must not
become selectable chat routes.
The referenced OpenCode source at fetched `origin/dev` commit
`147169e9b78bdd8430800f883af6b6485e5156e4` runs ordinary follow-up
prompts through a serial local queue in
`packages/opencode/src/cli/cmd/run/runtime.queue.ts`: prompts submitted
behind an active ordinary turn remain visible as queued prompts, expose
removal through `FooterApi.onQueuedRemove`, and are removed from the visible
queue before their own turn begins. Pulse's Assistant drawer adapts that
behavior by keeping queued follow-ups in the transcript, showing queue
position plus edit/remove controls on each queued user row, and draining
those rows through the existing chat-runtime queue without aborting the
active model stream.
The referenced OpenCode source at fetched `origin/dev` commit
`fa2b63f850fc0a23bec2bdff9e660450d3fe7913` keeps prompt/footer status visible
only while the session is non-idle in
`packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`, and maps
@@ -1,6 +1,7 @@
import { Component, Show, For, createEffect, createMemo } from 'solid-js';
import { MessageItem } from './MessageItem';
import type { ChatSession } from '@/api/aiChat';
import type { QueuedFollowUp } from './hooks/useChat';
import type {
ChatMessage,
ModelRouteRecoveryOption,
@@ -24,6 +25,9 @@ interface ChatMessagesProps {
getModelRouteLabel?: (modelId: string) => string;
getModelRouteAlternative?: (message: ChatMessage) => ModelRouteRecoveryOption | null;
onUseModelRoute?: (modelId: string, messageId?: string) => void;
queuedFollowUps?: QueuedFollowUp[];
onEditQueuedFollowUp?: (id: string) => void;
onCancelQueuedFollowUp?: (id: string) => void;
// Dashboard props
recentSessions?: ChatSession[];
onLoadSession?: (sessionId: string) => void;
@@ -63,6 +67,19 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
const recentSessions = createMemo(() =>
(props.recentSessions || []).filter((session) => session.message_count > 0).slice(0, 3),
);
const queuedFollowUpMetaByMessageId = createMemo(() => {
const entries = props.queuedFollowUps || [];
return new Map(
entries.map((entry, index) => [
entry.messageId,
{
id: entry.id,
position: index + 1,
count: entries.length,
},
]),
);
});
const formatSessionMessageCount = (count: number) =>
`${count} ${count === 1 ? 'message' : 'messages'}`;
@@ -145,22 +162,43 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
{/* Messages */}
<For each={props.messages}>
{(message) => (
<MessageItem
message={message}
onApprove={(approval) => props.onApprove(message.id, approval)}
onSkip={(toolId) => props.onSkip(message.id, toolId)}
onAnswerQuestion={(question, answers) =>
props.onAnswerQuestion(message.id, question, answers)
}
onSkipQuestion={(questionId) => props.onSkipQuestion(message.id, questionId)}
onRetry={props.onRetry}
onChangeModel={props.onChangeModel}
getModelRouteLabel={props.getModelRouteLabel}
modelRouteAlternative={props.getModelRouteAlternative?.(message)}
onUseModelRoute={props.onUseModelRoute}
/>
)}
{(message) => {
const queuedMeta = createMemo(() => queuedFollowUpMetaByMessageId().get(message.id));
return (
<MessageItem
message={message}
onApprove={(approval) => props.onApprove(message.id, approval)}
onSkip={(toolId) => props.onSkip(message.id, toolId)}
onAnswerQuestion={(question, answers) =>
props.onAnswerQuestion(message.id, question, answers)
}
onSkipQuestion={(questionId) => props.onSkipQuestion(message.id, questionId)}
onRetry={props.onRetry}
onChangeModel={props.onChangeModel}
getModelRouteLabel={props.getModelRouteLabel}
modelRouteAlternative={props.getModelRouteAlternative?.(message)}
onUseModelRoute={props.onUseModelRoute}
queuedPosition={queuedMeta()?.position}
queuedCount={queuedMeta()?.count}
onEditQueued={
queuedMeta() && props.onEditQueuedFollowUp
? () => {
const meta = queuedMeta();
if (meta) props.onEditQueuedFollowUp?.(meta.id);
}
: undefined
}
onCancelQueued={
queuedMeta() && props.onCancelQueuedFollowUp
? () => {
const meta = queuedMeta();
if (meta) props.onCancelQueuedFollowUp?.(meta.id);
}
: undefined
}
/>
);
}}
</For>
{/* Scroll anchor */}
@@ -14,8 +14,10 @@ import CircleAlertIcon from 'lucide-solid/icons/circle-alert';
import ClockIcon from 'lucide-solid/icons/clock';
import CopyIcon from 'lucide-solid/icons/copy';
import CpuIcon from 'lucide-solid/icons/cpu';
import PencilIcon from 'lucide-solid/icons/pencil';
import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw';
import SparklesIcon from 'lucide-solid/icons/sparkles';
import XIcon from 'lucide-solid/icons/x';
import { renderMarkdown } from '../aiChatUtils';
import { PendingToolBlock, ToolExecutionBlock } from './ToolExecutionBlock';
import { ApprovalCard } from './ApprovalCard';
@@ -51,6 +53,10 @@ interface MessageItemProps {
getModelRouteLabel?: (modelId: string) => string;
modelRouteAlternative?: ModelRouteRecoveryOption | null;
onUseModelRoute?: (modelId: string, messageId?: string) => void;
queuedPosition?: number;
queuedCount?: number;
onEditQueued?: () => void;
onCancelQueued?: () => void;
}
const markdownClass =
@@ -65,6 +71,15 @@ const markdownClass =
export const MessageItem: Component<MessageItemProps> = (props) => {
const isUser = () => props.message.role === 'user';
const isQueuedUserMessage = () => isUser() && props.message.delivery === 'queued';
const queuedStatusLabel = createMemo(() => {
if (!isQueuedUserMessage()) return '';
const position = props.queuedPosition;
const count = props.queuedCount;
if (position && count && count > 1) {
return `Queued ${position} of ${count}`;
}
return 'Queued';
});
// Group stream events into display blocks. Content collapses into a single
// block even when a reasoning model interleaves hidden thinking deltas, so
@@ -189,11 +204,33 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
<p class="text-sm whitespace-pre-wrap">{props.message.content}</p>
<Show when={isQueuedUserMessage()}>
<div
class="mt-1.5 flex items-center justify-end gap-1 text-[11px] font-medium text-blue-700 dark:text-blue-300"
class="mt-1.5 flex flex-wrap items-center justify-end gap-1.5 text-[11px] font-medium text-blue-700 dark:text-blue-300"
role="status"
>
<ClockIcon class="h-3 w-3" aria-hidden="true" />
<span>Queued</span>
<span>{queuedStatusLabel()}</span>
<Show when={props.onEditQueued}>
<button
type="button"
onClick={() => props.onEditQueued?.()}
aria-label="Edit queued follow-up"
title="Edit queued follow-up"
class="inline-flex h-5 w-5 items-center justify-center rounded text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 focus:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500/30 dark:text-blue-200 dark:hover:bg-blue-900/60"
>
<PencilIcon class="h-3 w-3" aria-hidden="true" />
</button>
</Show>
<Show when={props.onCancelQueued}>
<button
type="button"
onClick={() => props.onCancelQueued?.()}
aria-label="Remove queued follow-up"
title="Remove queued follow-up"
class="inline-flex h-5 w-5 items-center justify-center rounded text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 focus:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500/30 dark:text-blue-200 dark:hover:bg-blue-900/60"
>
<XIcon class="h-3 w-3" aria-hidden="true" />
</button>
</Show>
</div>
</Show>
</div>
@@ -24,6 +24,9 @@ const {
getModelRouteLabel?: (modelId: string) => string;
getModelRouteAlternative?: (message: ChatMessage) => ModelRouteRecoveryOption | null;
onUseModelRoute?: (modelId: string, messageId?: string) => void;
queuedFollowUps?: QueuedFollowUp[];
onEditQueuedFollowUp?: (id: string) => void;
onCancelQueuedFollowUp?: (id: string) => void;
}> = [];
const mockModelSelectorProps: Array<{
selectedModel: string;
@@ -206,6 +209,9 @@ vi.mock('../ChatMessages', () => ({
getModelRouteLabel?: (modelId: string) => string;
getModelRouteAlternative?: (message: ChatMessage) => ModelRouteRecoveryOption | null;
onUseModelRoute?: (modelId: string, messageId?: string) => void;
queuedFollowUps?: QueuedFollowUp[];
onEditQueuedFollowUp?: (id: string) => void;
onCancelQueuedFollowUp?: (id: string) => void;
}) => {
const routeRecovery = () => {
const failedMessage = props.messages.find((message) => message.error);
@@ -1528,6 +1534,31 @@ describe('AIChat', () => {
expect(mockChat.clearQueuedFollowUps).toHaveBeenCalledTimes(1);
});
it('passes queued follow-up metadata and row actions into the transcript', () => {
const queuedFollowUps: QueuedFollowUp[] = [
{
id: 'queued-row-1',
messageId: 'msg-queued-row-1',
prompt: 'row queued prompt',
timestamp: new Date(),
},
];
mockChat.queuedFollowUpCount.mockReturnValue(1);
mockChat.queuedFollowUps.mockReturnValue(queuedFollowUps);
mockChat.takeQueuedFollowUp.mockReturnValue(queuedFollowUps[0]);
renderChat();
const chatMessagesProps = mockChatMessagesProps.at(-1);
expect(chatMessagesProps?.queuedFollowUps).toBe(queuedFollowUps);
chatMessagesProps?.onEditQueuedFollowUp?.('queued-row-1');
expect(mockChat.takeQueuedFollowUp).toHaveBeenCalledWith('queued-row-1');
chatMessagesProps?.onCancelQueuedFollowUp?.('queued-row-1');
expect(mockChat.cancelQueuedFollowUp).toHaveBeenCalledWith('queued-row-1');
});
it('removes an individual queued follow-up', () => {
mockChat.queuedFollowUpCount.mockReturnValue(1);
mockChat.queuedFollowUps.mockReturnValue([
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { ChatMessages } from '../ChatMessages';
import type { QueuedFollowUp } from '../hooks/useChat';
import type {
ChatMessage,
ModelRouteRecoveryOption,
@@ -22,6 +23,10 @@ let capturedMessageItemProps: Array<{
getModelRouteLabel?: (modelId: string) => string;
modelRouteAlternative?: ModelRouteRecoveryOption | null;
onUseModelRoute?: (modelId: string, messageId?: string) => void;
queuedPosition?: number;
queuedCount?: number;
onEditQueued?: () => void;
onCancelQueued?: () => void;
}> = [];
vi.mock('../MessageItem', () => ({
@@ -38,6 +43,10 @@ vi.mock('../MessageItem', () => ({
getModelRouteLabel?: (modelId: string) => string;
modelRouteAlternative?: ModelRouteRecoveryOption | null;
onUseModelRoute?: (modelId: string, messageId?: string) => void;
queuedPosition?: number;
queuedCount?: number;
onEditQueued?: () => void;
onCancelQueued?: () => void;
}) => {
capturedMessageItemProps.push(props);
return (
@@ -249,6 +258,60 @@ describe('ChatMessages', () => {
expect(asstMsg).toHaveAttribute('data-role', 'assistant');
});
it('passes queue position and row actions to queued message items', () => {
const onEditQueuedFollowUp = vi.fn();
const onCancelQueuedFollowUp = vi.fn();
const queuedFollowUps: QueuedFollowUp[] = [
{
id: 'queue-1',
messageId: 'queued-user-1',
prompt: 'first queued turn',
timestamp: new Date('2026-03-01T12:01:00Z'),
},
{
id: 'queue-2',
messageId: 'queued-user-2',
prompt: 'second queued turn',
timestamp: new Date('2026-03-01T12:02:00Z'),
},
];
render(() => (
<ChatMessages
messages={[
makeMessage({
id: 'queued-user-1',
role: 'user',
content: 'first queued turn',
delivery: 'queued',
}),
makeMessage({
id: 'queued-user-2',
role: 'user',
content: 'second queued turn',
delivery: 'queued',
}),
]}
{...makeHandlers()}
queuedFollowUps={queuedFollowUps}
onEditQueuedFollowUp={onEditQueuedFollowUp}
onCancelQueuedFollowUp={onCancelQueuedFollowUp}
/>
));
const firstQueued = capturedMessageItemProps.find((p) => p.message.id === 'queued-user-1');
const secondQueued = capturedMessageItemProps.find((p) => p.message.id === 'queued-user-2');
expect(firstQueued).toMatchObject({ queuedPosition: 1, queuedCount: 2 });
expect(secondQueued).toMatchObject({ queuedPosition: 2, queuedCount: 2 });
firstQueued?.onEditQueued?.();
secondQueued?.onCancelQueued?.();
expect(onEditQueuedFollowUp).toHaveBeenCalledWith('queue-1');
expect(onCancelQueuedFollowUp).toHaveBeenCalledWith('queue-2');
});
it('renders the scroll anchor element', () => {
const { container } = render(() => (
<ChatMessages messages={[makeMessage()]} {...makeHandlers()} />
@@ -131,7 +131,9 @@ describe('MessageItem', () => {
expect(p.tagName).toBe('P');
});
it('renders queued user messages with a status marker', () => {
it('renders queued user messages with queue position and row actions', () => {
const onEditQueued = vi.fn();
const onCancelQueued = vi.fn();
render(() => (
<MessageItem
message={makeMessage({
@@ -140,11 +142,21 @@ describe('MessageItem', () => {
delivery: 'queued',
})}
{...makeHandlers()}
queuedPosition={2}
queuedCount={3}
onEditQueued={onEditQueued}
onCancelQueued={onCancelQueued}
/>
));
expect(screen.getByText('follow up after this')).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Queued');
expect(screen.getByRole('status')).toHaveTextContent('Queued 2 of 3');
fireEvent.click(screen.getByRole('button', { name: 'Edit queued follow-up' }));
expect(onEditQueued).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Remove queued follow-up' }));
expect(onCancelQueued).toHaveBeenCalledTimes(1);
});
});
@@ -2315,6 +2315,12 @@ export const AIChat: Component<AIChatProps> = (props) => {
getModelRouteLabel={formatChatMessageModelRoute}
getModelRouteAlternative={getFailedTurnModelRouteAlternative}
onUseModelRoute={switchToModelRoute}
queuedFollowUps={chat.queuedFollowUps()}
onEditQueuedFollowUp={editQueuedFollowUp}
onCancelQueuedFollowUp={(id) => {
chat.cancelQueuedFollowUp(id);
focusComposer();
}}
recentSessions={sessions()
.filter((s) => s.id !== chat.sessionId() && s.message_count > 0)
.slice(0, 3)}