diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index dbbb2545e..a8ce461e3 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -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 + /** Which channel `onSteer` rides (`useSessionFeedback().channel`); picks + * the composer's honest copy. See `MessageInput`. */ + steerChannel?: "native" | "pull" onAddFeedback?: () => void feedbackAddDisabled?: boolean /** @@ -125,6 +128,7 @@ export const ChatInput = memo(function ChatInput({ onSaveQueueEdit, onCancelQueueEdit, onSteer, + steerChannel, onAddFeedback, feedbackAddDisabled, allowOfflineCompose = false, @@ -220,6 +224,7 @@ export const ChatInput = memo(function ChatInput({ onSaveQueueEdit={onSaveQueueEdit} onCancelQueueEdit={onCancelQueueEdit} onSteer={onSteer} + steerChannel={steerChannel} onAddFeedback={onAddFeedback} feedbackAddDisabled={feedbackAddDisabled} injectContent={injectContent} diff --git a/src/components/chat/conversation-shell.tsx b/src/components/chat/conversation-shell.tsx index 7e4eb5343..2fb3a5286 100644 --- a/src/components/chat/conversation-shell.tsx +++ b/src/components/chat/conversation-shell.tsx @@ -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 + /** 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. */ @@ -200,6 +203,7 @@ export function ConversationShell({ onSaveQueueEdit, onCancelQueueEdit, onSteer, + steerChannel, topBanner, injectContent, onInjectConsumed, @@ -370,6 +374,7 @@ export function ConversationShell({ onSaveQueueEdit={onSaveQueueEdit} onCancelQueueEdit={onCancelQueueEdit} onSteer={onSteer} + steerChannel={steerChannel} onAddFeedback={onAddFeedback} feedbackAddDisabled={feedbackAddDisabled} injectContent={injectContent} diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index f6512c0f6..e635b34fb 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -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" + ) + ) + }) }) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index de21cfa85..aabc19d63 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -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 + /** 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 @@ -323,6 +330,7 @@ export function MessageInput({ onSaveQueueEdit, onCancelQueueEdit, onSteer, + steerChannel = "native", onAddFeedback, feedbackAddDisabled, injectContent, @@ -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 @@ -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) @@ -1314,6 +1328,7 @@ export function MessageInput({ showModeSelector, effectiveModeId, resetComposer, + steerChannel, t, ]) @@ -1629,11 +1644,14 @@ export function MessageInput({ ) : 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.
@@ -1671,7 +1691,7 @@ export function MessageInput({ disabled={steering} > - {t("steerIntoTurn")} + {t(steerChannel === "pull" ? "steerAsNote" : "steerIntoTurn")} diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index f6052b452..9c35d7095 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -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[]) => { @@ -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) diff --git a/src/hooks/use-session-feedback.test.ts b/src/hooks/use-session-feedback.test.ts index 9bfb756a0..91944c08c 100644 --- a/src/hooks/use-session-feedback.test.ts +++ b/src/hooks/use-session-feedback.test.ts @@ -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 () => { @@ -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 () => { diff --git a/src/hooks/use-session-feedback.ts b/src/hooks/use-session-feedback.ts index 8d4db19e9..cc7469d20 100644 --- a/src/hooks/use-session-feedback.ts +++ b/src/hooks/use-session-feedback.ts @@ -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). */ @@ -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 @@ -425,6 +430,7 @@ export function useSessionFeedback({ featureEnabled: enabled, canSubmit, channel, + steerAvailable, showList, submitting, dialogOpen, @@ -438,6 +444,7 @@ export function useSessionFeedback({ enabled, canSubmit, channel, + steerAvailable, showList, submitting, dialogOpen, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d596812f0..0b0d6eab5 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2900,8 +2900,10 @@ "send": "إرسال", "queueMessage": "إضافة إلى قائمة الانتظار", "steerIntoTurn": "إدراج في الدور الحالي", + "steerAsNote": "إرسال ملاحظة للفحص التالي", "steerQueuedInstead": "أُضيفت إلى قائمة الانتظار — ستُرسل مع الدور التالي.", "steerFailed": "تعذّر الإدراج في الدور الحالي", + "steerNoteFailed": "تعذّر إرسال ملاحظتك", "slashCommands": "أوامر الشرطة المائلة", "slashSearchPlaceholder": "البحث عن الأوامر...", "slashSearchEmpty": "لا توجد أوامر مطابقة", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 7cc608930..917042e56 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -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", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6ed241707..ac1757fb1 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2902,8 +2902,10 @@ "send": "Send", "queueMessage": "Queue message", "steerIntoTurn": "Insert into current turn", + "steerAsNote": "Send note for next check", "steerQueuedInstead": "Queued instead — it will be sent with the next turn.", "steerFailed": "Couldn't insert into the current turn", + "steerNoteFailed": "Couldn't send your note", "slashCommands": "Slash commands", "slashSearchPlaceholder": "Search commands...", "slashSearchEmpty": "No matching commands", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 457db3b4f..7d747ecb7 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2900,8 +2900,10 @@ "send": "Enviar", "queueMessage": "Poner en cola", "steerIntoTurn": "Insertar en el turno actual", + "steerAsNote": "Enviar nota para la próxima comprobación", "steerQueuedInstead": "Se puso en cola: se enviará con el siguiente turno.", "steerFailed": "No se pudo insertar en el turno actual", + "steerNoteFailed": "No se pudo enviar tu nota", "slashCommands": "Comandos de barra", "slashSearchPlaceholder": "Buscar comandos...", "slashSearchEmpty": "Sin comandos coincidentes", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index fd9c2f8a9..480e1635b 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2900,8 +2900,10 @@ "send": "Envoyer", "queueMessage": "Mettre en file d'attente", "steerIntoTurn": "Insérer dans le tour en cours", + "steerAsNote": "Envoyer une note pour la prochaine vérification", "steerQueuedInstead": "Mis en file d'attente — il sera envoyé au tour suivant.", "steerFailed": "Impossible d'insérer dans le tour en cours", + "steerNoteFailed": "Impossible d'envoyer votre note", "slashCommands": "Commandes slash", "slashSearchPlaceholder": "Rechercher des commandes...", "slashSearchEmpty": "Aucune commande correspondante", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index ddaca4ccd..6ab41949f 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2900,8 +2900,10 @@ "send": "送信", "queueMessage": "キューに追加", "steerIntoTurn": "現在のターンに挿入", + "steerAsNote": "次の確認用にメモを送信", "steerQueuedInstead": "代わりにキューに追加しました。次のターンで送信されます。", "steerFailed": "現在のターンに挿入できませんでした", + "steerNoteFailed": "メモを送信できませんでした", "slashCommands": "スラッシュコマンド", "slashSearchPlaceholder": "コマンドを検索...", "slashSearchEmpty": "一致するコマンドがありません", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 5fd2cd814..35875ca9c 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2900,8 +2900,10 @@ "send": "보내기", "queueMessage": "대기열에 추가", "steerIntoTurn": "현재 턴에 삽입", + "steerAsNote": "다음 확인용 메모 보내기", "steerQueuedInstead": "대신 대기열에 추가되었습니다. 다음 턴에 전송됩니다.", "steerFailed": "현재 턴에 삽입하지 못했습니다", + "steerNoteFailed": "메모를 보내지 못했습니다", "slashCommands": "슬래시 명령", "slashSearchPlaceholder": "명령 검색...", "slashSearchEmpty": "일치하는 명령이 없습니다", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 7da8317f4..d93e60ee0 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2900,8 +2900,10 @@ "send": "Enviar", "queueMessage": "Adicionar à fila", "steerIntoTurn": "Inserir no turno atual", + "steerAsNote": "Enviar nota para a próxima verificação", "steerQueuedInstead": "Adicionado à fila — será enviado no próximo turno.", "steerFailed": "Não foi possível inserir no turno atual", + "steerNoteFailed": "Não foi possível enviar a sua nota", "slashCommands": "Comandos de barra", "slashSearchPlaceholder": "Buscar comandos...", "slashSearchEmpty": "Nenhum comando correspondente", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 42a142673..2e4ed5822 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2902,8 +2902,10 @@ "send": "发送", "queueMessage": "加入队列", "steerIntoTurn": "插入当前回合", + "steerAsNote": "发送留言,供下次检查时读取", "steerQueuedInstead": "已转入队列——将随下一回合发送。", "steerFailed": "无法插入当前回合", + "steerNoteFailed": "留言发送失败", "slashCommands": "斜杠命令", "slashSearchPlaceholder": "搜索命令...", "slashSearchEmpty": "没有匹配的命令", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 1d76b2266..840fbfd9f 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2900,8 +2900,10 @@ "send": "傳送", "queueMessage": "加入佇列", "steerIntoTurn": "插入目前回合", + "steerAsNote": "傳送留言,供下次檢查時讀取", "steerQueuedInstead": "已轉入佇列——將隨下一回合傳送。", "steerFailed": "無法插入目前回合", + "steerNoteFailed": "留言送出失敗", "slashCommands": "斜線命令", "slashSearchPlaceholder": "搜尋命令...", "slashSearchEmpty": "沒有符合的指令",