diff --git a/ui/src/pages/chat/chat-composer-actions.test.ts b/ui/src/pages/chat/chat-composer-actions.test.ts index 8612af1235c8..c278adcc295a 100644 --- a/ui/src/pages/chat/chat-composer-actions.test.ts +++ b/ui/src/pages/chat/chat-composer-actions.test.ts @@ -294,10 +294,10 @@ describe("renderChatComposer controls", () => { textarea.value = liveDraft; } - pressComposerEnter(container, modifiers); + const action = pressComposerEnter(container, modifiers); expect(onSend).toHaveBeenCalledOnce(); - expect(onSend).toHaveBeenCalledWith("steer"); + expect(onSend).toHaveBeenCalledWith("steer", action); }, ); @@ -319,9 +319,27 @@ describe("renderChatComposer controls", () => { sendShortcut, }); - pressComposerEnter(container, { altKey, ctrlKey: true }); + const action = pressComposerEnter(container, { altKey, ctrlKey: true }); - expect(onSend.mock.calls).toEqual([[]]); + expect(onSend.mock.calls).toEqual([[undefined, action]]); + }, + ); + + it.each(["keyboard", "pointer"] as const)( + "passes the original %s submission event through the composer", + (kind) => { + const onSend = vi.fn(); + const { container } = renderComposer({ draft: "Repeat this message", onSend }); + const action = + kind === "keyboard" + ? pressComposerEnter(container) + : new MouseEvent("click", { bubbles: true, cancelable: true }); + + if (kind === "pointer") { + primaryButton(container).dispatchEvent(action); + } + + expect(onSend).toHaveBeenCalledWith(undefined, action); }, ); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 9f41c98f9155..458a9d236037 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -529,7 +529,7 @@ export class ChatPane extends ChatPaneLayoutRender { state.requestUpdate?.(); }, onRemoveAttachment: this.removeBrowserAnnotation, - onSend: (followUpModeOverride) => + onSend: (followUpModeOverride, submissionAction) => catalogKey ? void this.continueCatalogSession(catalogKey) : suggestionViewer @@ -537,6 +537,7 @@ export class ChatPane extends ChatPaneLayoutRender { : void state.handleSendChat( undefined, followUpModeOverride ? { followUpMode: followUpModeOverride } : undefined, + submissionAction, ), onCompact: sessionActionCallbacks.onCompact, // Checkpoint deep-link carries the archived filter so the row stays findable. diff --git a/ui/src/pages/chat/chat-send-submit.ts b/ui/src/pages/chat/chat-send-submit.ts index ea8754d06d8f..3be87b5fd84e 100644 --- a/ui/src/pages/chat/chat-send-submit.ts +++ b/ui/src/pages/chat/chat-send-submit.ts @@ -195,6 +195,7 @@ export async function handleSendChat( host: ChatHost, messageOverride?: string, opts?: ChatSendSubmitOptions, + submissionAction?: Event, ) { const previousDraft = host.chatMessage; const userMessage = (messageOverride ?? host.chatMessage).trim(); @@ -470,7 +471,7 @@ export async function handleSendChat( // Keep their guards independent so submitting one cannot suppress the other. const submitKind = requestedEditId ? "queued-edit" : "message"; const submitKey = chatSubmitKey(host, submitKind, effectiveMessage, attachmentsToSend); - await withChatSubmitGuard(host, submitKey, async () => { + const submitMessage = async () => { if (host.chatLoading) { // A terminal event can render before its authoritative leaf arrives. // Reuse the in-flight history request before fencing the follow-up send. @@ -593,7 +594,8 @@ export async function handleSendChat( // The reconnect queue owns the quote; later offline turns must not reuse it. host.chatReplyTarget = null; } - }); + }; + await withChatSubmitGuard(host, submitKey, submitMessage, submissionAction); } function prependReplyQuote( diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 4deb8d61ad9d..27648d238083 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -5821,6 +5821,26 @@ describe("handleSendChat", () => { expect(host.chatMessages).toStrictEqual([]); }); + it("queues identical messages from distinct user actions while coalescing re-entry", async () => { + const sent = createDeferred(); + const host = makeChatHost({ + requestHandlers: { "chat.send": () => sent.promise }, + }); + const firstAction = new Event("submit"); + const secondAction = new Event("submit"); + + const first = handleSendChat(host, "same prompt", undefined, firstAction); + const reentry = handleSendChat(host, "same prompt", undefined, firstAction); + const second = handleSendChat(host, "same prompt", undefined, secondAction); + + expect(host.request.mock.calls.filter(([method]) => method === "chat.send")).toHaveLength(1); + expect(host.chatQueue).toHaveLength(2); + expect(host.chatQueue.map((item) => item.text)).toEqual(["same prompt", "same prompt"]); + + sent.resolve({ runId: host.chatQueue[0]?.sendRunId, status: "started" }); + await Promise.all([first, reentry, second]); + }); + it("keeps an acknowledged live send pending while durable history is briefly stale", async () => { let historyRequests = 0; let runId: string | undefined; diff --git a/ui/src/pages/chat/chat-state-controller.ts b/ui/src/pages/chat/chat-state-controller.ts index d0b9b705318b..0919dbf6dd0b 100644 --- a/ui/src/pages/chat/chat-state-controller.ts +++ b/ui/src/pages/chat/chat-state-controller.ts @@ -89,8 +89,8 @@ export class ChatStateController implements Reactiv state.requestUpdate = () => renderLifecycle.invalidate(); this.cleanups.push(subscribeChatOutboxProjection(state)); const sendChat = state.handleSendChat; - state.handleSendChat = async (messageOverride, options) => { - const pending = sendChat(messageOverride, options); + state.handleSendChat = async (messageOverride, options, submissionAction) => { + const pending = sendChat(messageOverride, options, submissionAction); renderLifecycle.invalidate(); try { await pending; diff --git a/ui/src/pages/chat/chat-state-host.ts b/ui/src/pages/chat/chat-state-host.ts index 553c06d94874..5667faeb7267 100644 --- a/ui/src/pages/chat/chat-state-host.ts +++ b/ui/src/pages/chat/chat-state-host.ts @@ -135,7 +135,11 @@ export type ChatPageHost = ChatHost & handleChatScroll: (event: Event) => void; handleChatDraftChange: (next: string) => void; handleChatInputHistoryKey: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult; - handleSendChat: (messageOverride?: string, options?: unknown) => Promise; + handleSendChat: ( + messageOverride?: string, + options?: unknown, + submissionAction?: Event, + ) => Promise; handleAbortChat: (options?: unknown) => Promise; removeQueuedMessage: (id: string) => void; retryQueuedChatMessage: (id: string) => Promise; diff --git a/ui/src/pages/chat/chat-state-page.ts b/ui/src/pages/chat/chat-state-page.ts index c555b97113db..6780a34b9aca 100644 --- a/ui/src/pages/chat/chat-state-page.ts +++ b/ui/src/pages/chat/chat-state-page.ts @@ -293,7 +293,7 @@ export function createPageState( }; attachChatRealtimeActions(state); state.loadAssistantIdentity = () => loadPageAssistantIdentity(state); - state.handleSendChat = (messageOverride, options) => { + state.handleSendChat = (messageOverride, options, submissionAction) => { const message = messageOverride ?? state.chatMessage; const isCommand = parseSlashCommand(message) !== null || @@ -312,7 +312,7 @@ export function createPageState( ) { autoPromptNotificationsOnSend(context); } - return handleSendChat(state, messageOverride, options as never); + return handleSendChat(state, messageOverride, options as never, submissionAction); }; state.handleAbortChat = async (options) => { await handleAbortChat(state, options as never); diff --git a/ui/src/pages/chat/chat-submit-guard.ts b/ui/src/pages/chat/chat-submit-guard.ts index 87daa4413f8b..f067ce84e51e 100644 --- a/ui/src/pages/chat/chat-submit-guard.ts +++ b/ui/src/pages/chat/chat-submit-guard.ts @@ -1,25 +1,35 @@ +import { generateUUID } from "../../lib/uuid.ts"; import type { ChatHost } from "./chat-send-contract.ts"; +const submissionActionIds = new WeakMap(); + export async function withChatSubmitGuard( host: ChatHost, key: string, run: () => Promise, + action?: Event, ): Promise { + let guardKey = key; + if (action) { + const actionId = submissionActionIds.get(action) ?? generateUUID(); + submissionActionIds.set(action, actionId); + guardKey = `${key}\0${actionId}`; + } const guards = (host.chatSubmitGuards ??= new Map>()); - if (guards.has(key)) { + if (guards.has(guardKey)) { return undefined; } let releaseGuard!: () => void; const guard = new Promise((resolve) => { releaseGuard = resolve; }); - guards.set(key, guard); + guards.set(guardKey, guard); try { return await run(); } finally { releaseGuard(); - if (guards.get(key) === guard) { - guards.delete(key); + if (guards.get(guardKey) === guard) { + guards.delete(guardKey); } } } diff --git a/ui/src/pages/chat/components/chat-composer-controls.ts b/ui/src/pages/chat/components/chat-composer-controls.ts index c027657d6abc..23410520aff8 100644 --- a/ui/src/pages/chat/components/chat-composer-controls.ts +++ b/ui/src/pages/chat/components/chat-composer-controls.ts @@ -36,7 +36,7 @@ export type ChatRunControlsProps = { onDictationPointerDown?: (event: PointerEvent) => void; onPrimaryActionPointerDown?: (event: PointerEvent) => void; onAbort?: () => void; - onSend: () => void; + onSend: (submissionAction?: Event) => void; onToggleVoice?: () => void; onToggleCamera?: () => void; microphonePicker?: TemplateResult | typeof nothing; @@ -222,8 +222,8 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) { const activeRunActionTooltip = queueSteerShortcutAvailable ? `${activeRunActionLabel} ⏎ · ${t("chat.queue.steer")} ${t("chat.sendShortcutModifierEnter")}` : activeRunActionLabel; - // Lit passes the click event to handlers; keep it out of the scalar send override. - const send = () => props.onSend(); + // Preserve the click identity without mistaking it for a follow-up mode. + const send = (event: Event) => props.onSend(event); const abortAction = props.canAbort ? html` diff --git a/ui/src/pages/chat/components/chat-composer-keydown.ts b/ui/src/pages/chat/components/chat-composer-keydown.ts index 7fed2231aad3..bf867ce35448 100644 --- a/ui/src/pages/chat/components/chat-composer-keydown.ts +++ b/ui/src/pages/chat/components/chat-composer-keydown.ts @@ -207,9 +207,9 @@ export function createComposerKeyDownHandler({ commitDraft(target.value); const steerImmediately = steerNowEnabled && (event.metaKey || event.ctrlKey) && !event.altKey; if (steerImmediately) { - props.onSend("steer"); + props.onSend("steer", event); } else { - props.onSend(); + props.onSend(undefined, event); } syncDraftAfterSend(target); } diff --git a/ui/src/pages/chat/components/chat-composer-types.ts b/ui/src/pages/chat/components/chat-composer-types.ts index d7bc20a5ad3a..e0323f93e8f9 100644 --- a/ui/src/pages/chat/components/chat-composer-types.ts +++ b/ui/src/pages/chat/components/chat-composer-types.ts @@ -126,7 +126,7 @@ export type ChatComposerProps = ChatAttachmentControlsProps & { onDraftChange: (next: string) => void; onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult; onSlashIntent?: () => void | Promise; - onSend: (followUpModeOverride?: "steer") => void; + onSend: (followUpModeOverride?: "steer", submissionAction?: Event) => void; onCompact?: () => void | Promise; onToggleRealtimeTalk?: () => void; onToggleRealtimeCamera?: () => void; diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index a03c4a045a70..d24327d36bce 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -327,7 +327,7 @@ export function renderChatComposer(props: ChatComposerProps) { commitComposerDraft(props, target.value); props.onTypingChange?.(false); }; - const handleSend = () => { + const handleSend = (submissionAction?: Event) => { const draft = state.composerTextarea?.value ?? props.draft; if (!canSubmitDraft(draft)) { return; @@ -336,7 +336,7 @@ export function renderChatComposer(props: ChatComposerProps) { state.composingDraft = null; commitComposerDraft(props, draft); props.onTypingChange?.(false); - props.onSend(); + props.onSend(undefined, submissionAction); syncComposerDraftAfterSend(state.composerTextarea); }; const handleVoicePrimaryAction = () => {