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-input.tsx b/src/components/chat/message-input.tsx index b268b33586..6510131c28 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -47,6 +47,7 @@ import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { imageFilesFromClipboardApi } from "@/lib/clipboard-images" import { toErrorMessage } from "@/lib/app-error" import { isNoActiveTurnRejection } from "@/lib/turn-busy" +import { buildSteerPayload } from "@/lib/prompt-draft" import { ServerFileBrowserDialog } from "@/components/shared/server-file-browser-dialog" import { toast } from "sonner" import type { @@ -1379,19 +1380,11 @@ export function MessageInput({ resetComposer() toast.info(t("steerQueuedInstead")) } - const blocks = draft.blocks.some((b) => b.type !== "text") - ? draft.blocks - : undefined - const text = blocks - ? draft.displayText - : draft.blocks - .map((b) => (b.type === "text" ? b.text : "")) - .join("\n") - .trim() - if (!text) return + const payload = buildSteerPayload(draft) + if (!payload) return setSteering(true) try { - await onSteer(text, blocks) + await onSteer(payload.text, payload.blocks) resetComposer() } catch (err) { if (isNoActiveTurnRejection(err)) { 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..629b2e3e25 --- /dev/null +++ b/src/components/chat/message-queue-display.test.tsx @@ -0,0 +1,145 @@ +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, + } +} + +/** A queued draft that carries an attachment — only the native wire takes it. */ +function itemWithAttachment(id: string, text: string): QueuedMessage { + return { + id, + draft: { + blocks: [ + { type: "text", text }, + { + type: "resource", + uri: "clipboard://shot.png-1", + mime_type: "image/png", + text: null, + blob: "QUJD", + }, + ], + 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)) + }) + + it("hides the note button on a pull row the channel cannot carry", () => { + // The pull tool delivers text only, so the backend rejects a draft with + // attachments there — the click could never insert anything. The row is + // still sent whole (attachment included) by the queue's own flush. + renderDisplay({ + queue: [item("q1", "use pnpm"), itemWithAttachment("q2", "look at this")], + onSteerItem: vi.fn(async () => {}), + steerChannel: "pull", + }) + expect(screen.getAllByTitle(TQ.steerItemAsNote)).toHaveLength(1) + }) + + it("keeps the attachment row insertable on the native wire", () => { + renderDisplay({ + queue: [item("q1", "use pnpm"), itemWithAttachment("q2", "look at this")], + onSteerItem: vi.fn(async () => {}), + steerChannel: "native", + }) + expect(screen.getAllByTitle(TQ.steerItemNow)).toHaveLength(2) + }) + + it("hides the insert on the row being edited (the composer owns its text)", () => { + // While a row is under edit its real content lives in the composer; + // inserting would send the stale pre-edit draft and drop the row the save + // was headed for. + renderDisplay({ + onSteerItem: vi.fn(async () => {}), + steerChannel: "native", + editingItemId: "q1", + }) + expect(screen.getAllByTitle(TQ.steerItemNow)).toHaveLength(1) + }) +}) diff --git a/src/components/chat/message-queue-display.tsx b/src/components/chat/message-queue-display.tsx index 1b8b88385c..7bfb1734ed 100644 --- a/src/components/chat/message-queue-display.tsx +++ b/src/components/chat/message-queue-display.tsx @@ -1,10 +1,11 @@ "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 { draftRidesBlocks } from "@/lib/prompt-draft" import type { QueuedMessage } from "@/hooks/use-message-queue" interface MessageQueueDisplayProps { @@ -13,6 +14,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 +34,12 @@ interface QueueItemProps { isEditing: boolean onEdit: (id: string) => void onDelete: (id: string) => void + onSteerItem?: (id: string) => Promise | void + steerChannel: "native" | "pull" + /** Whether an insert from ANY row is in flight — every row's button is + * disabled for the duration, so two rows can't race the one channel. */ + steering: boolean + onSteerStart: (id: string) => Promise } function QueueItem({ @@ -29,10 +48,29 @@ function QueueItem({ isEditing, onEdit, onDelete, + onSteerItem, + steerChannel, + steering, + onSteerStart, }: QueueItemProps) { const t = useTranslations("Folder.chat.messageQueue") const dragControls = useDragControls() + // Which rows may offer the insert at all. Two rows can't, and offering a + // click that provably does nothing is worse than offering none: + // * The row under edit. Its authoritative text is in the composer now, so + // inserting would send the PRE-edit draft and then drop the row the edit + // was going to save into. (The composer hides its own mid-turn send while + // editing for the same reason.) + // * A draft carrying attachments on a pull-tool session. The pull channel + // delivers text, so the backend rejects blocks there — every click would + // land on the turn-end fallback, which for an already-queued row is a + // no-op. It still goes out whole with the next turn, via the queue. + const canSteer = + Boolean(onSteerItem) && + !isEditing && + (steerChannel === "native" || !draftRidesBlocks(item.draft)) + const startDrag = useCallback( (event: PointerEvent) => { event.preventDefault() @@ -67,6 +105,23 @@ function QueueItem({ {item.draft.displayText} + {canSteer && ( + + )}