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
19 changes: 12 additions & 7 deletions src/components/chat/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,20 @@ interface ChatInputProps {
isEditingQueueItem?: boolean
onSaveQueueEdit?: (draft: PromptDraft) => void
onCancelQueueEdit?: () => void
/** Inject the draft into the RUNNING turn over the native steering channel.
* Present only when the session's live-feedback channel is native
* (`useSessionFeedback().channel === "native"`); resolves once recorded,
* rejects on any failure (incl. the turn-end race) so MessageInput can run
* its own enqueue fallback / draft preservation. `blocks` carries the full
* draft when it holds more than plain text (image attachments, file
* badges); `text` stays the recorded/display form. Must stay in sync with
/** Send the draft into the RUNNING turn over the session's live-feedback
* channel. Present only when the session has a working delivery channel
* (`useSessionFeedback().steerAvailable`); resolves once recorded, rejects
* on any failure (incl. the turn-end race) so MessageInput can run its own
* enqueue fallback / draft preservation. `blocks` carries the full draft
* when it holds more than plain text (image attachments, file badges);
* `text` stays the recorded/display form. Must stay in sync with
* `MessageInputProps.onSteer` — the optional second parameter makes a
* stale one-arg declaration here assignable, so tsc would NOT catch a
* wrapper that silently drops the blocks. */
onSteer?: (text: string, blocks?: PromptInputBlock[]) => Promise<void>
/** Which channel `onSteer` rides (`useSessionFeedback().channel`); picks
* the composer's honest copy. See `MessageInput`. */
steerChannel?: "native" | "pull"
onAddFeedback?: () => void
feedbackAddDisabled?: boolean
/**
Expand Down Expand Up @@ -125,6 +128,7 @@ export const ChatInput = memo(function ChatInput({
onSaveQueueEdit,
onCancelQueueEdit,
onSteer,
steerChannel,
onAddFeedback,
feedbackAddDisabled,
allowOfflineCompose = false,
Expand Down Expand Up @@ -220,6 +224,7 @@ export const ChatInput = memo(function ChatInput({
onSaveQueueEdit={onSaveQueueEdit}
onCancelQueueEdit={onCancelQueueEdit}
onSteer={onSteer}
steerChannel={steerChannel}
onAddFeedback={onAddFeedback}
feedbackAddDisabled={feedbackAddDisabled}
injectContent={injectContent}
Expand Down
21 changes: 13 additions & 8 deletions src/components/chat/conversation-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,18 @@ interface ConversationShellProps {
isEditingQueueItem?: boolean
onSaveQueueEdit?: (draft: PromptDraft) => void
onCancelQueueEdit?: () => void
/** Inject the draft into the RUNNING turn (native live-feedback steering).
* Present only for sessions on the native channel; threaded straight
* through to the composer. `blocks` carries the full draft when it holds
* more than plain text (image attachments, file badges); `text` stays the
* recorded/display form. Must stay in sync with `MessageInputProps.onSteer`
* — the optional second parameter makes a stale one-arg declaration here
* assignable, so tsc would NOT catch a wrapper that silently drops the
* blocks. */
/** Send the draft into the RUNNING turn over the session's live-feedback
* channel. Present only for sessions with a working delivery channel;
* threaded straight through to the composer. `blocks` carries the full
* draft when it holds more than plain text (image attachments, file
* badges); `text` stays the recorded/display form. Must stay in sync with
* `MessageInputProps.onSteer` — the optional second parameter makes a
* stale one-arg declaration here assignable, so tsc would NOT catch a
* wrapper that silently drops the blocks. */
onSteer?: (text: string, blocks?: PromptInputBlock[]) => Promise<void>
/** Which channel `onSteer` rides (picks the composer's honest copy);
* threaded straight through. See `MessageInput`. */
steerChannel?: "native" | "pull"
/** Optional banner pinned to the top of the panel, above the message area
* (e.g. the "restart to apply" config-stale banner). Renders nothing when
* omitted. */
Expand Down Expand Up @@ -200,6 +203,7 @@ export function ConversationShell({
onSaveQueueEdit,
onCancelQueueEdit,
onSteer,
steerChannel,
topBanner,
injectContent,
onInjectConsumed,
Expand Down Expand Up @@ -370,6 +374,7 @@ export function ConversationShell({
onSaveQueueEdit={onSaveQueueEdit}
onCancelQueueEdit={onCancelQueueEdit}
onSteer={onSteer}
steerChannel={steerChannel}
onAddFeedback={onAddFeedback}
feedbackAddDisabled={feedbackAddDisabled}
injectContent={injectContent}
Expand Down
28 changes: 28 additions & 0 deletions src/components/chat/message-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1281,4 +1281,32 @@ describe("MessageInput native steering (insert into current turn)", () => {
expect(serializeDocToText(editor.state.doc)).not.toContain("late note")
)
})

it("labels the mid-turn action honestly on the pull channel", async () => {
// A pull-tool session gets the same split, but its action must never
// promise an instant insert: the note is recorded as waiting and read on
// the agent's next check, so the copy says exactly that.
const user = userEvent.setup()
const onSteer = vi.fn().mockResolvedValue(undefined)
const editor = await mountPrompting({ onSteer, steerChannel: "pull" })
typeDraft(editor, "check the tests")
await waitFor(() =>
expect(screen.getByLabelText(MI.steerAsNote)).toBeInTheDocument()
)
expect(screen.queryByLabelText(MI.steerIntoTurn)).toBeNull()

// The action itself rides the same steer path — only the copy differs.
await user.click(screen.getByLabelText(MI.steerAsNote))
await user.click(
await screen.findByRole("menuitem", { name: MI.steerAsNote })
)
await waitFor(() =>
expect(onSteer).toHaveBeenCalledWith("check the tests", undefined)
)
await waitFor(() =>
expect(serializeDocToText(editor.state.doc)).not.toContain(
"check the tests"
)
)
})
})
72 changes: 46 additions & 26 deletions src/components/chat/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,15 +209,22 @@ interface MessageInputProps {
isEditingQueueItem?: boolean
onSaveQueueEdit?: (draft: PromptDraft) => void
onCancelQueueEdit?: () => void
/** Inject the draft into the RUNNING turn (native live-feedback steering).
* Present only on sessions whose feedback channel is native — when absent,
* the prompting branch renders its historical Stop-only form. `text` is
* the recorded/display form; `blocks` carries the full draft whenever it
* holds more than plain text (image attachments, file badges), encoded
* exactly like a normal send. Awaited: resolve = injected + recorded
* (clear the draft); reject = failure, where a turn-end `NoActiveTurn`
* race falls back to the queue and anything else keeps the draft. */
/** Send the draft into the RUNNING turn over the session's live-feedback
* channel (see {@link steerChannel}). Present only on sessions with a
* working delivery channel — when absent, the prompting branch renders its
* historical Stop-only form. `text` is the recorded/display form; `blocks`
* carries the full draft whenever it holds more than plain text (image
* attachments, file badges), encoded exactly like a normal send. Awaited:
* resolve = recorded (clear the draft); reject = failure, where a turn-end
* `NoActiveTurn` race falls back to the queue and anything else keeps the
* draft. */
onSteer?: (text: string, blocks?: PromptInputBlock[]) => Promise<void>
/** Which channel {@link onSteer} rides (`useSessionFeedback().channel`).
* Picks the honest copy for the mid-turn action: `native` = inserted into
* the turn immediately, `pull` = recorded as a note the agent reads on its
* next `check_user_feedback` call. Defaults to `native` (the only channel
* that offered the action before this prop existed). */
steerChannel?: "native" | "pull"
/** Open the live-feedback dialog (from the "+" menu). When omitted the entry
* is hidden (feature off). */
onAddFeedback?: () => void
Expand Down Expand Up @@ -323,6 +330,7 @@ export function MessageInput({
onSaveQueueEdit,
onCancelQueueEdit,
onSteer,
steerChannel = "native",
onAddFeedback,
feedbackAddDisabled,
injectContent,
Expand Down Expand Up @@ -1255,16 +1263,19 @@ export function MessageInput({
resetComposer,
])

// Mid-turn "insert into current turn" (native steering). Awaited, unlike
// the synchronous send/enqueue paths: the draft clears ONLY once the
// backend confirms the injection was recorded — a turn-end race falls back
// to the queue (the note is never lost), any other failure keeps the draft
// for retry. A draft that holds more than plain text (image attachments,
// file badges) steers as its full block list — the same encoding a normal
// send uses, which the native wire carries verbatim — with the display text
// as the recorded note; nothing is silently stripped. Unsettled uploads are
// gated here exactly like `handleSend` (no server-side uri to hydrate from
// yet), since the enqueue fallback below bypasses its gate.
// Mid-turn send over the session's live-feedback channel: a native push
// inserts into the running turn; a pull-tool session records a waiting note
// the agent reads on its next check (the copy is keyed on `steerChannel` so
// neither overpromises). Awaited, unlike the synchronous send/enqueue
// paths: the draft clears ONLY once the backend confirms the note was
// recorded — a turn-end race falls back to the queue (the note is never
// lost), any other failure keeps the draft for retry. A draft that holds
// more than plain text (image attachments, file badges) steers as its full
// block list — the same encoding a normal send uses, carried verbatim on
// either channel — with the display text as the recorded note; nothing is
// silently stripped. Unsettled uploads are gated here exactly like
// `handleSend` (no server-side uri to hydrate from yet), since the enqueue
// fallback below bypasses its gate.
const [steering, setSteering] = useState(false)
const handleSteerClick = useCallback(async () => {
if (!onSteer || steering) return
Expand Down Expand Up @@ -1299,7 +1310,10 @@ export function MessageInput({
// The turn ended in the race window — reroute through the queue.
enqueueInstead()
} else {
toast.error(t("steerFailed"), { description: toErrorMessage(err) })
toast.error(
t(steerChannel === "pull" ? "steerNoteFailed" : "steerFailed"),
{ description: toErrorMessage(err) }
)
}
} finally {
setSteering(false)
Expand All @@ -1314,6 +1328,7 @@ export function MessageInput({
showModeSelector,
effectiveModeId,
resetComposer,
steerChannel,
t,
])

Expand Down Expand Up @@ -1629,11 +1644,14 @@ export function MessageInput({
</div>
) : isPrompting && onCancel ? (
onSteer && onEnqueue && hasSendableContent ? (
// Native-steering sessions surface the mid-turn actions that already
// exist but were keyboard-only/invisible: the primary half of the split
// queues the draft (what Enter has always done here), the dropdown
// injects it into the RUNNING turn. Without `onSteer` this branch stays
// pixel-identical to the historical Stop-only form below.
// Sessions with a working live-feedback channel surface the mid-turn
// actions that already exist but were keyboard-only/invisible: the
// primary half of the split queues the draft (what Enter has always
// done here), the dropdown sends it over the channel — a native push
// inserts into the RUNNING turn, a pull-tool session records a waiting
// note for the agent's next check (label keyed on `steerChannel`).
// Without `onSteer` this branch stays pixel-identical to the
// historical Stop-only form below.
<div className="flex items-center gap-1">
<Button
onClick={onCancel}
Expand All @@ -1660,7 +1678,9 @@ export function MessageInput({
disabled={steering}
size="icon"
className="h-8 w-5 rounded-l-none border-l border-primary-foreground/20"
aria-label={t("steerIntoTurn")}
aria-label={t(
steerChannel === "pull" ? "steerAsNote" : "steerIntoTurn"
)}
>
<ChevronUp className="size-4" />
</Button>
Expand All @@ -1671,7 +1691,7 @@ export function MessageInput({
disabled={steering}
>
<Zap className="h-4 w-4" />
{t("steerIntoTurn")}
{t(steerChannel === "pull" ? "steerAsNote" : "steerIntoTurn")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
Expand Down
23 changes: 14 additions & 9 deletions src/components/conversations/conversation-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2019,11 +2019,12 @@ const ConversationTabView = memo(function ConversationTabView({
steeredMessageIds: conn.steeredMessageIds,
onResendAsPrompt: resendFeedbackAsPrompt,
})
// Composer "insert into current turn" (native steering only). Rethrows —
// MessageInput owns the enqueue fallback and draft-preservation policy, so
// this wrapper must not swallow the turn-end race the way `submit` does.
// `blocks` rides along when the draft carries attachments (images steer
// too); `text` stays the recorded/display form.
// Composer mid-turn send, over whichever live-feedback channel this session
// has (native push or the pull tool). Rethrows — MessageInput owns the
// enqueue fallback and draft-preservation policy, so this wrapper must not
// swallow the turn-end race the way `submit` does. `blocks` rides along when
// the draft carries attachments (images steer too); `text` stays the
// recorded/display form.
const feedbackSteer = feedback.steer
const handleSteer = useCallback(
async (text: string, blocks?: PromptInputBlock[]) => {
Expand Down Expand Up @@ -2115,13 +2116,17 @@ const ConversationTabView = memo(function ConversationTabView({
onSaveQueueEdit={handleSaveQueueEdit}
onCancelQueueEdit={handleQueueCancelEdit}
onSteer={
// Native channel only: on pull sessions the prompting branch must
// stay pixel-identical (Stop button alone). The prompting scope
// itself is enforced where the button renders.
feedback.featureEnabled && feedback.channel === "native"
// Any working delivery channel, not just the native push: the pull
// tool records a waiting note the agent reads on its next check, and
// `steerChannel` swaps the copy so pull sessions never promise an
// instant insert. Sessions with NEITHER channel keep the historical
// prompting branch (Stop button alone, Enter queues). The prompting
// scope itself is enforced where the button renders.
feedback.featureEnabled && feedback.steerAvailable
? handleSteer
: undefined
}
steerChannel={feedback.channel}
>
{isWelcomeMode ? (
// Same overlay scrollbar as the sidebar / file lists (os-theme-codeg)
Expand Down
21 changes: 21 additions & 0 deletions src/hooks/use-session-feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ describe("useSessionFeedback", () => {

await waitFor(() => expect(result.current.canSubmit).toBe(true))
expect(result.current.channel).toBe("native")
expect(result.current.steerAvailable).toBe(true)
})

it("stays on the pull channel when only the tool is available", async () => {
Expand All @@ -295,6 +296,26 @@ describe("useSessionFeedback", () => {

await waitFor(() => expect(result.current.canSubmit).toBe(true))
expect(result.current.channel).toBe("pull")
// The pull tool is a working delivery channel — the composer's mid-turn
// send must be offered here too, not only on native sessions.
expect(result.current.steerAvailable).toBe(true)
})

it("reports no steer channel when the session has neither", async () => {
// No tool (launched before the feature was enabled / agent without MCP)
// and no native steering: the composer must keep its historical Stop-only
// prompting form, so `steerAvailable` stays false even mid-turn.
mockSnapshot.mockResolvedValue(
snapshot({
feedback_tool_available: false,
native_steering_available: false,
})
)
const { result } = renderHook(() => useSessionFeedback(baseProps))

await waitFor(() => expect(mockSnapshot).toHaveBeenCalled())
expect(result.current.steerAvailable).toBe(false)
expect(result.current.canSubmit).toBe(false)
})

it("steer appends optimistically on success and RETHROWS on failure", async () => {
Expand Down
19 changes: 13 additions & 6 deletions src/hooks/use-session-feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,16 @@ export interface UseSessionFeedback {
/** Which channel a note would ride: `native` = the ACP `_session/steering`
* push (injected into the running turn immediately), `pull` = the
* `check_user_feedback` MCP tool (read when the agent next checks). Drives
* copy and the composer's "insert into current turn" entry. Backend-
* synthesized — never derived from agent type here. */
* copy and the composer's mid-turn send entry. Backend-synthesized — never
* derived from agent type here. */
channel: "native" | "pull"
/** Whether THIS session has a working mid-turn delivery channel at all:
* native push or the pull tool. Gates the composer's mid-turn send
* affordance (`channel` picks its copy); a session with neither keeps the
* historical Stop-only prompting form. Distinct from `canSubmit`, which
* additionally folds in the prompting scope — the composer enforces that
* where the button renders. */
steerAvailable: boolean
/** Whether to render the read-only notes list above the composer. */
showList: boolean
/** Whether a submit is in flight (disables the dialog send button). */
Expand Down Expand Up @@ -402,11 +409,9 @@ export function useSessionFeedback({
const openDialog = useCallback(() => setDialogOpen(true), [])
const closeDialog = useCallback(() => setDialogOpen(false), [])

const steerAvailable = toolAvailable || nativeSteering
const canSubmit =
enabled &&
Boolean(connectionId) &&
(toolAvailable || nativeSteering) &&
isPrompting
enabled && Boolean(connectionId) && steerAvailable && isPrompting
const channel: "native" | "pull" = nativeSteering ? "native" : "pull"
// Drop the notes the transcript is already rendering as user turns. Kept as
// a derivation rather than a filter on `setNotes` so a note stays recoverable
Expand All @@ -425,6 +430,7 @@ export function useSessionFeedback({
featureEnabled: enabled,
canSubmit,
channel,
steerAvailable,
showList,
submitting,
dialogOpen,
Expand All @@ -438,6 +444,7 @@ export function useSessionFeedback({
enabled,
canSubmit,
channel,
steerAvailable,
showList,
submitting,
dialogOpen,
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -2900,8 +2900,10 @@
"send": "إرسال",
"queueMessage": "إضافة إلى قائمة الانتظار",
"steerIntoTurn": "إدراج في الدور الحالي",
"steerAsNote": "إرسال ملاحظة للفحص التالي",
"steerQueuedInstead": "أُضيفت إلى قائمة الانتظار — ستُرسل مع الدور التالي.",
"steerFailed": "تعذّر الإدراج في الدور الحالي",
"steerNoteFailed": "تعذّر إرسال ملاحظتك",
"slashCommands": "أوامر الشرطة المائلة",
"slashSearchPlaceholder": "البحث عن الأوامر...",
"slashSearchEmpty": "لا توجد أوامر مطابقة",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2900,8 +2900,10 @@
"send": "Senden",
"queueMessage": "In Warteschlange stellen",
"steerIntoTurn": "In laufenden Turn einfügen",
"steerAsNote": "Notiz für die nächste Prüfung senden",
"steerQueuedInstead": "Stattdessen in die Warteschlange gestellt – wird mit dem nächsten Turn gesendet.",
"steerFailed": "Konnte nicht in den laufenden Turn eingefügt werden",
"steerNoteFailed": "Deine Notiz konnte nicht gesendet werden",
"slashCommands": "Slash-Befehle",
"slashSearchPlaceholder": "Befehle suchen...",
"slashSearchEmpty": "Keine passenden Befehle",
Expand Down
Loading
Loading