mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-24 20:22:53 +00:00
Show Assistant queued follow-up row controls
This commit is contained in:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user