Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/components/chat/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void
editingItemId?: string | null
editingDraftText?: string | null
editingDraftBlocks?: PromptInputBlock[] | null
Expand Down Expand Up @@ -121,6 +126,7 @@ export const ChatInput = memo(function ChatInput({
onQueueReorder,
onQueueEdit,
onQueueDelete,
onQueueSteer,
editingItemId,
editingDraftText,
editingDraftBlocks,
Expand Down Expand Up @@ -187,6 +193,8 @@ export const ChatInput = memo(function ChatInput({
onEdit={onQueueEdit}
onDelete={onQueueDelete}
editingItemId={editingItemId ?? null}
onSteerItem={onQueueSteer}
steerChannel={steerChannel}
/>
)}
<MessageInput
Expand Down
6 changes: 6 additions & 0 deletions src/components/chat/conversation-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ interface ConversationShellProps {
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; threaded straight through to the composer's
* queue list. See `ChatInputProps.onQueueSteer`. */
onQueueSteer?: (id: string) => Promise<void> | void
editingItemId?: string | null
editingDraftText?: string | null
editingDraftBlocks?: PromptInputBlock[] | null
Expand Down Expand Up @@ -196,6 +200,7 @@ export function ConversationShell({
onQueueReorder,
onQueueEdit,
onQueueDelete,
onQueueSteer,
editingItemId,
editingDraftText,
editingDraftBlocks,
Expand Down Expand Up @@ -368,6 +373,7 @@ export function ConversationShell({
onQueueReorder={onQueueReorder}
onQueueEdit={onQueueEdit}
onQueueDelete={onQueueDelete}
onQueueSteer={onQueueSteer}
editingItemId={editingItemId}
editingDraftText={editingDraftText}
editingDraftBlocks={editingDraftBlocks}
Expand Down
15 changes: 4 additions & 11 deletions src/components/chat/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)) {
Expand Down
145 changes: 145 additions & 0 deletions src/components/chat/message-queue-display.test.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof MessageQueueDisplay>> = {}
) {
return render(
<NextIntlClientProvider locale="en" messages={enMessages}>
<MessageQueueDisplay
queue={[item("q1", "use pnpm"), item("q2", "run the tests")]}
onReorder={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
editingItemId={null}
{...props}
/>
</NextIntlClientProvider>
)
}

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<void>((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)
})
})
88 changes: 86 additions & 2 deletions src/components/chat/message-queue-display.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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> | 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 {
Expand All @@ -21,6 +34,12 @@ interface QueueItemProps {
isEditing: boolean
onEdit: (id: string) => void
onDelete: (id: string) => void
onSteerItem?: (id: string) => Promise<void> | 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<void>
}

function QueueItem({
Expand All @@ -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<HTMLButtonElement>) => {
event.preventDefault()
Expand Down Expand Up @@ -67,6 +105,23 @@ function QueueItem({
<span className="min-w-0 flex-1 truncate text-3xs text-foreground/80">
{item.draft.displayText}
</span>
{canSteer && (
<button
type="button"
onClick={() => void onSteerStart(item.id)}
disabled={steering}
className="shrink-0 rounded-sm p-0.5 hover:bg-muted-foreground/15 text-muted-foreground disabled:opacity-50"
title={t(
steerChannel === "pull" ? "steerItemAsNote" : "steerItemNow"
)}
>
{steerChannel === "pull" ? (
<Clock className="h-2.5 w-2.5" />
) : (
<Zap className="h-2.5 w-2.5" />
)}
</button>
)}
<button
type="button"
onClick={() => onEdit(item.id)}
Expand All @@ -93,7 +148,32 @@ export function MessageQueueDisplay({
onEdit,
onDelete,
editingItemId,
onSteerItem,
steerChannel = "pull",
}: MessageQueueDisplayProps) {
// The id whose insert is in flight. A per-row `steering` would let a
// concurrent click on another row race the same channel; one shared id
// both disables the clicked row and (via `steeringId !== null`) the others
// — matching the composer's single-flight `steering` guard.
const [steeringId, setSteeringId] = useState<string | null>(null)
// Latest steeringId for the click handler's re-entrancy check without
// re-binding it on every state commit.
const steeringIdRef = useRef<string | null>(null)
steeringIdRef.current = steeringId

const handleSteerStart = useCallback(
async (id: string) => {
if (!onSteerItem || steeringIdRef.current !== null) return
setSteeringId(id)
try {
await onSteerItem(id)
} finally {
setSteeringId(null)
}
},
[onSteerItem]
)

if (queue.length === 0) return null

return (
Expand All @@ -113,6 +193,10 @@ export function MessageQueueDisplay({
isEditing={editingItemId === item.id}
onEdit={onEdit}
onDelete={onDelete}
onSteerItem={onSteerItem}
steerChannel={steerChannel}
steering={steeringId !== null}
onSteerStart={handleSteerStart}
/>
))}
</Reorder.Group>
Expand Down
Loading
Loading