From cc6e4876d2895081f93b0b1dc24c9b1081027fda Mon Sep 17 00:00:00 2001 From: lihong Date: Tue, 15 Sep 2026 23:27:16 +0800 Subject: [PATCH 1/4] feat(chat): click a queued message to insert it into the running turn The queue rows above the composer only offered reorder/edit/delete, so a queued prompt had to wait for the turn to end even on sessions where the composer can steer a draft into the RUNNING turn (native `_session/steering` push, or the `check_user_feedback` pull note). Surface that same delivery per-row: each queued item gets an insert button riding the session's live- feedback channel, with the composer's honest copy (Zap = instant insert on native, Clock = waiting note on pull). - message-queue-display: per-row steer button + single-flight guard so two rows can't race the same channel. - conversation-detail-panel: handleQueueSteer reuses `feedback.steer` with the same block/text encoding as the composer's send; success removes the row, the turn-end NoActiveTurn race keeps it queued (auto-flush delivers it with the next turn), any other failure keeps the row and toasts. - The button is offered only when the feature is on, the session has a working channel, and a turn is in flight. --- src/components/chat/chat-input.tsx | 8 ++ src/components/chat/conversation-shell.tsx | 6 ++ .../chat/message-queue-display.test.tsx | 91 +++++++++++++++++++ src/components/chat/message-queue-display.tsx | 69 +++++++++++++- .../conversation-detail-panel.tsx | 60 +++++++++++- src/i18n/messages/ar.json | 4 +- src/i18n/messages/de.json | 4 +- src/i18n/messages/en.json | 4 +- src/i18n/messages/es.json | 4 +- src/i18n/messages/fr.json | 4 +- src/i18n/messages/ja.json | 4 +- src/i18n/messages/ko.json | 4 +- src/i18n/messages/pt.json | 4 +- src/i18n/messages/zh-CN.json | 4 +- src/i18n/messages/zh-TW.json | 4 +- 15 files changed, 261 insertions(+), 13 deletions(-) create mode 100644 src/components/chat/message-queue-display.test.tsx diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index a8ce461e35..6700ffd27d 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -52,6 +52,11 @@ interface ChatInputProps { onQueueReorder?: (items: QueuedMessage[]) => void onQueueEdit?: (id: string) => void onQueueDelete?: (id: string) => void + /** Insert one queued item into the RUNNING turn over the session's + * live-feedback channel (see `MessageQueueDisplayProps.onSteerItem`). + * Threaded straight through; present only while a turn is in flight and + * the session has a working channel. */ + onQueueSteer?: (id: string) => Promise | void editingItemId?: string | null editingDraftText?: string | null editingDraftBlocks?: PromptInputBlock[] | null @@ -121,6 +126,7 @@ export const ChatInput = memo(function ChatInput({ onQueueReorder, onQueueEdit, onQueueDelete, + onQueueSteer, editingItemId, editingDraftText, editingDraftBlocks, @@ -187,6 +193,8 @@ export const ChatInput = memo(function ChatInput({ onEdit={onQueueEdit} onDelete={onQueueDelete} editingItemId={editingItemId ?? null} + onSteerItem={onQueueSteer} + steerChannel={steerChannel} /> )} void onQueueEdit?: (id: string) => void onQueueDelete?: (id: string) => void + /** Insert one queued item into the RUNNING turn over the session's + * live-feedback channel; threaded straight through to the composer's + * queue list. See `ChatInputProps.onQueueSteer`. */ + onQueueSteer?: (id: string) => Promise | void editingItemId?: string | null editingDraftText?: string | null editingDraftBlocks?: PromptInputBlock[] | null @@ -196,6 +200,7 @@ export function ConversationShell({ onQueueReorder, onQueueEdit, onQueueDelete, + onQueueSteer, editingItemId, editingDraftText, editingDraftBlocks, @@ -368,6 +373,7 @@ export function ConversationShell({ onQueueReorder={onQueueReorder} onQueueEdit={onQueueEdit} onQueueDelete={onQueueDelete} + onQueueSteer={onQueueSteer} editingItemId={editingItemId} editingDraftText={editingDraftText} editingDraftBlocks={editingDraftBlocks} diff --git a/src/components/chat/message-queue-display.test.tsx b/src/components/chat/message-queue-display.test.tsx new file mode 100644 index 0000000000..c54df54335 --- /dev/null +++ b/src/components/chat/message-queue-display.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, cleanup, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, describe, expect, it, vi } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import type { QueuedMessage } from "@/hooks/use-message-queue" + +import { MessageQueueDisplay } from "./message-queue-display" + +const TQ = enMessages.Folder.chat.messageQueue + +function item(id: string, text: string): QueuedMessage { + return { + id, + draft: { blocks: [{ type: "text", text }], displayText: text }, + modeId: null, + } +} + +function renderDisplay( + props: Partial> = {} +) { + return render( + + + + ) +} + +afterEach(cleanup) + +describe("MessageQueueDisplay click-to-insert", () => { + it("offers no insert button without a steering handler", () => { + renderDisplay() + expect(screen.queryByTitle(TQ.steerItemNow)).toBeNull() + expect(screen.queryByTitle(TQ.steerItemAsNote)).toBeNull() + }) + + it("inserts the row the button belongs to, with the native promise", async () => { + const onSteerItem = vi.fn(async () => {}) + renderDisplay({ onSteerItem, steerChannel: "native" }) + + const buttons = screen.getAllByTitle(TQ.steerItemNow) + expect(buttons).toHaveLength(2) + + await userEvent.click(buttons[1]) + expect(onSteerItem).toHaveBeenCalledWith("q2") + }) + + it("keys the copy to the pull channel (waiting note, not an insert)", async () => { + const onSteerItem = vi.fn(async () => {}) + renderDisplay({ onSteerItem, steerChannel: "pull" }) + + // The insert label must not appear on a pull session — it would promise + // an instant injection the channel can't deliver. + expect(screen.queryByTitle(TQ.steerItemNow)).toBeNull() + await userEvent.click(screen.getAllByTitle(TQ.steerItemAsNote)[0]) + expect(onSteerItem).toHaveBeenCalledWith("q1") + }) + + it("disables every row while an insert is in flight (single-flight)", async () => { + let release: () => void = () => {} + const onSteerItem = vi.fn( + () => + new Promise((resolve) => { + release = resolve + }) + ) + renderDisplay({ onSteerItem, steerChannel: "native" }) + + const buttons = screen.getAllByTitle(TQ.steerItemNow) as HTMLButtonElement[] + await userEvent.click(buttons[0]) + await waitFor(() => expect(onSteerItem).toHaveBeenCalledTimes(1)) + + expect(buttons[1].disabled).toBe(true) + // A second click while in flight must not race the same channel. + await userEvent.click(buttons[1]) + expect(onSteerItem).toHaveBeenCalledTimes(1) + + release() + await waitFor(() => expect(buttons[1].disabled).toBe(false)) + }) +}) diff --git a/src/components/chat/message-queue-display.tsx b/src/components/chat/message-queue-display.tsx index 1b8b88385c..f9441fec22 100644 --- a/src/components/chat/message-queue-display.tsx +++ b/src/components/chat/message-queue-display.tsx @@ -1,8 +1,8 @@ "use client" -import { useCallback, type PointerEvent } from "react" +import { useCallback, useRef, useState, type PointerEvent } from "react" import { Reorder, useDragControls } from "motion/react" -import { GripVertical, Pencil, X } from "lucide-react" +import { Clock, GripVertical, Pencil, X, Zap } from "lucide-react" import { useTranslations } from "next-intl" import { cn } from "@/lib/utils" import type { QueuedMessage } from "@/hooks/use-message-queue" @@ -13,6 +13,18 @@ interface MessageQueueDisplayProps { onEdit: (id: string) => void onDelete: (id: string) => void editingItemId: string | null + /** + * Send one queued item straight into the RUNNING turn over the session's + * live-feedback channel (same delivery as the composer's mid-turn send). + * Present only while the session has a working channel AND a turn is in + * flight; the host decides whether the row is removed (it stays queued on + * the turn-end race). Resolves once delivery is settled. + */ + onSteerItem?: (id: string) => Promise | void + /** Which channel {@link onSteerItem} rides; picks the honest icon/copy, + * mirroring the composer's split-button (`Zap` = instant insert, + * `Clock` = note the agent reads on its next check). */ + steerChannel?: "native" | "pull" } interface QueueItemProps { @@ -21,6 +33,11 @@ interface QueueItemProps { isEditing: boolean onEdit: (id: string) => void onDelete: (id: string) => void + onSteerItem?: (id: string) => Promise | void + steerChannel: "native" | "pull" + /** Whether an insert from THIS row is in flight (disables its button). */ + steering: boolean + onSteerStart: (id: string) => Promise } function QueueItem({ @@ -29,6 +46,10 @@ function QueueItem({ isEditing, onEdit, onDelete, + onSteerItem, + steerChannel, + steering, + onSteerStart, }: QueueItemProps) { const t = useTranslations("Folder.chat.messageQueue") const dragControls = useDragControls() @@ -67,6 +88,21 @@ function QueueItem({ {item.draft.displayText} + {onSteerItem && ( + + )}