diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a0efa5788..df8662c52 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx index 86cee2d40..f7a89bddc 100644 --- a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx +++ b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx @@ -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 = (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 = (props) => { {/* Messages */} - {(message) => ( - 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 ( + 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 + } + /> + ); + }} {/* Scroll anchor */} diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 7497b5dca..9b329cd95 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -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 = (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 = (props) => {

{props.message.content}

diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index e8624184d..935f41929 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -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([ diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx index 780628ca8..e66c0fa0c 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx @@ -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(() => ( + + )); + + 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(() => ( diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index 7c7bf553d..abf85266a 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -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(() => ( { 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); }); }); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index ae21cfc23..750d4a6c4 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -2315,6 +2315,12 @@ export const AIChat: Component = (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)}