From 91d529b4cf5806369facb06e4eec36a702038eaf Mon Sep 17 00:00:00 2001 From: Brian Coleman Date: Sat, 9 May 2026 22:14:54 -0600 Subject: [PATCH 1/2] Add composer submit keybinding setting - support Enter shortcut variants - persist and validate client setting - rename composer send state helpers --- .../settings/DesktopClientSettings.test.ts | 1 + .../web/src/components/ChatView.logic.test.ts | 8 +-- apps/web/src/components/ChatView.logic.ts | 2 +- apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/chat/ChatComposer.tsx | 23 ++++--- .../components/settings/SettingsPanels.tsx | 67 +++++++++++++++++++ apps/web/src/composer-logic.test.ts | 50 ++++++++++++++ apps/web/src/composer-logic.ts | 38 +++++++++++ apps/web/src/localApi.test.ts | 2 + packages/contracts/src/settings.test.ts | 31 ++++++++- packages/contracts/src/settings.ts | 15 +++++ 11 files changed, 222 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..a55b1cbf71f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, + composerSubmitKeybinding: "shiftEnter", confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 83c90edaddc..0f2cc268726 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -15,7 +15,7 @@ import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, - deriveComposerSendState, + deriveComposerSubmitState, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, resolveSendEnvMode, @@ -25,9 +25,9 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); -describe("deriveComposerSendState", () => { +describe("deriveComposerSubmitState", () => { it("treats expired terminal pills as non-sendable content", () => { - const state = deriveComposerSendState({ + const state = deriveComposerSubmitState({ prompt: "\uFFFC", imageCount: 0, terminalContexts: [ @@ -51,7 +51,7 @@ describe("deriveComposerSendState", () => { }); it("keeps text sendable while excluding expired terminal pills", () => { - const state = deriveComposerSendState({ + const state = deriveComposerSubmitState({ prompt: `yoo \uFFFC waddup`, imageCount: 0, terminalContexts: [ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index bf87add28d9..628b797e4df 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -180,7 +180,7 @@ export function cloneComposerImageForRetry( } } -export function deriveComposerSendState(options: { +export function deriveComposerSubmitState(options: { prompt: string; imageCount: number; terminalContexts: ReadonlyArray; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b84aa11ca6..3d68bf9ae2b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -156,7 +156,7 @@ import { buildLocalDraftThread, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, - deriveComposerSendState, + deriveComposerSubmitState, hasServerAcknowledgedLocalDispatch, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -2641,7 +2641,7 @@ export default function ChatView(props: ChatViewProps) { sendableTerminalContexts: sendableComposerTerminalContexts, expiredTerminalContextCount, hasSendableContent, - } = deriveComposerSendState({ + } = deriveComposerSubmitState({ prompt: promptForSend, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2c4743de3c6..7ea3ad61df0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -40,8 +40,9 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, replaceTextRange, + shouldSubmitComposerOnEnter, } from "../../composer-logic"; -import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { deriveComposerSubmitState, readFileAsDataUrl } from "../ChatView.logic"; import { type ComposerImageAttachment, type DraftId, @@ -817,11 +818,11 @@ export const ChatComposer = memo( const dragDepthRef = useRef(0); // ------------------------------------------------------------------ - // Derived: composer send state + // Derived: composer submit state // ------------------------------------------------------------------ - const composerSendState = useMemo( + const composerSubmitState = useMemo( () => - deriveComposerSendState({ + deriveComposerSubmitState({ prompt, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, @@ -973,11 +974,11 @@ export const ChatComposer = memo( if (showPlanFollowUpPrompt) { return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; } - return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; + return `idle:${composerSubmitState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; }, [ activePendingIsResponding, activePendingProgress, - composerSendState.hasSendableContent, + composerSubmitState.hasSendableContent, isConnecting, isPreparingWorktree, isSendBusy, @@ -1053,7 +1054,7 @@ export const ChatComposer = memo( [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], ); const collapsedComposerPrimaryActionDisabled = - phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent; + phase === "running" || isSendBusy || isConnecting || !composerSubmitState.hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; @@ -1605,11 +1606,11 @@ export const ChatComposer = memo( if (activePendingProgress) { return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } - return showPlanFollowUpPrompt || composerSendState.hasSendableContent; + return showPlanFollowUpPrompt || composerSubmitState.hasSendableContent; }, [ activePendingProgress, activePendingResolvedAnswers, - composerSendState.hasSendableContent, + composerSubmitState.hasSendableContent, isConnecting, isMobileViewport, isSendBusy, @@ -1678,7 +1679,7 @@ export const ChatComposer = memo( return true; } } - if (key === "Enter" && !event.shiftKey) { + if (shouldSubmitComposerOnEnter(settings.composerSubmitKeybinding, event)) { submitComposer(); return true; } @@ -2406,7 +2407,7 @@ export const ChatComposer = memo( isConnecting={isConnecting} isEnvironmentUnavailable={environmentUnavailable !== null} isPreparingWorktree={isPreparingWorktree} - hasSendableContent={composerSendState.hasSendableContent} + hasSendableContent={composerSubmitState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 505be5c73f8..e10621a982c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { useCallback, useMemo, useRef, useState } from "react"; import { + type ComposerSubmitKeybinding, defaultInstanceIdForDriver, type DesktopUpdateChannel, PROVIDER_DISPLAY_NAMES, @@ -99,6 +100,26 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const COMPOSER_SUBMIT_KEYBINDING_OPTIONS = [ + { value: "enter", label: "Enter (Default)" }, + { value: "shiftEnter", label: "Enter + shift" }, + { value: "metaEnter", label: "Enter + Command/Meta" }, + { value: "altEnter", label: "Enter + Option/Alt" }, + { value: "ctrlEnter", label: "Enter + Ctrl" }, + { value: "buttonOnly", label: "Button only" }, +] as const satisfies ReadonlyArray<{ + readonly value: ComposerSubmitKeybinding; + readonly label: string; +}>; + +const COMPOSER_SUBMIT_KEYBINDING_LABELS = Object.fromEntries( + COMPOSER_SUBMIT_KEYBINDING_OPTIONS.map((option) => [option.value, option.label]), +) as Record; + +function isComposerSubmitKeybinding(value: string | null): value is ComposerSubmitKeybinding { + return COMPOSER_SUBMIT_KEYBINDING_OPTIONS.some((option) => option.value === value); +} + const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); function withoutProviderInstanceKey( @@ -405,6 +426,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ["Auto-open task panel"] : []), + ...(settings.composerSubmitKeybinding !== DEFAULT_UNIFIED_SETTINGS.composerSubmitKeybinding + ? ["Composer submit"] + : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -432,6 +456,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, + settings.composerSubmitKeybinding, settings.defaultThreadEnvMode, settings.diffIgnoreWhitespace, settings.diffWordWrap, @@ -460,6 +485,7 @@ export function useSettingsRestore(onRestored?: () => void) { diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, + composerSubmitKeybinding: DEFAULT_UNIFIED_SETTINGS.composerSubmitKeybinding, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, @@ -695,6 +721,47 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + composerSubmitKeybinding: DEFAULT_UNIFIED_SETTINGS.composerSubmitKeybinding, + }) + } + /> + ) : null + } + control={ + + } + /> + = {}, +) => ({ + key: modifiers.key ?? "Enter", + shiftKey: modifiers.shiftKey ?? false, + metaKey: modifiers.metaKey ?? false, + altKey: modifiers.altKey ?? false, + ctrlKey: modifiers.ctrlKey ?? false, +}); + +describe("shouldSubmitComposerOnEnter", () => { + it("matches exact configured Enter shortcuts", () => { + expect(shouldSubmitComposerOnEnter("enter", enterEvent())).toBe(true); + expect(shouldSubmitComposerOnEnter("shiftEnter", enterEvent({ shiftKey: true }))).toBe(true); + expect(shouldSubmitComposerOnEnter("metaEnter", enterEvent({ metaKey: true }))).toBe(true); + expect(shouldSubmitComposerOnEnter("altEnter", enterEvent({ altKey: true }))).toBe(true); + expect(shouldSubmitComposerOnEnter("ctrlEnter", enterEvent({ ctrlKey: true }))).toBe(true); + }); + + it("does not submit on non-matching modifiers", () => { + expect(shouldSubmitComposerOnEnter("enter", enterEvent({ shiftKey: true }))).toBe(false); + expect(shouldSubmitComposerOnEnter("shiftEnter", enterEvent())).toBe(false); + expect( + shouldSubmitComposerOnEnter("metaEnter", enterEvent({ metaKey: true, shiftKey: true })), + ).toBe(false); + expect( + shouldSubmitComposerOnEnter("altEnter", enterEvent({ altKey: true, ctrlKey: true })), + ).toBe(false); + }); + + it("lets every Enter shortcut insert text when button-only is selected", () => { + expect(shouldSubmitComposerOnEnter("buttonOnly", enterEvent())).toBe(false); + expect(shouldSubmitComposerOnEnter("buttonOnly", enterEvent({ shiftKey: true }))).toBe(false); + expect(shouldSubmitComposerOnEnter("buttonOnly", enterEvent({ metaKey: true }))).toBe(false); + expect(shouldSubmitComposerOnEnter("buttonOnly", enterEvent({ altKey: true }))).toBe(false); + expect(shouldSubmitComposerOnEnter("buttonOnly", enterEvent({ ctrlKey: true }))).toBe(false); + }); + + it("ignores non-Enter keys", () => { + expect(shouldSubmitComposerOnEnter("enter", enterEvent({ key: "Tab" }))).toBe(false); + }); +}); + describe("detectComposerTrigger", () => { it("detects @path trigger at cursor", () => { const text = "Please check @src/com"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index fb63d2581c7..a687b3edf75 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,5 +1,6 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; +import type { ComposerSubmitKeybinding } from "@t3tools/contracts/settings"; export type ComposerTriggerKind = "path" | "slash-command" | "skill"; export type ComposerSlashCommand = "model" | "plan" | "default"; @@ -11,6 +12,43 @@ export interface ComposerTrigger { rangeEnd: number; } +export interface ComposerEnterKeyEventLike { + readonly key: string; + readonly shiftKey: boolean; + readonly metaKey: boolean; + readonly altKey: boolean; + readonly ctrlKey: boolean; +} + +export function shouldSubmitComposerOnEnter( + keybinding: ComposerSubmitKeybinding, + event: ComposerEnterKeyEventLike, +): boolean { + if (event.key !== "Enter" || keybinding === "buttonOnly") { + return false; + } + + const modifiers = { + shift: event.shiftKey, + meta: event.metaKey, + alt: event.altKey, + ctrl: event.ctrlKey, + }; + + switch (keybinding) { + case "enter": + return !modifiers.shift && !modifiers.meta && !modifiers.alt && !modifiers.ctrl; + case "shiftEnter": + return modifiers.shift && !modifiers.meta && !modifiers.alt && !modifiers.ctrl; + case "metaEnter": + return modifiers.meta && !modifiers.shift && !modifiers.alt && !modifiers.ctrl; + case "altEnter": + return modifiers.alt && !modifiers.shift && !modifiers.meta && !modifiers.ctrl; + case "ctrlEnter": + return modifiers.ctrl && !modifiers.shift && !modifiers.meta && !modifiers.alt; + } +} + const isInlineTokenSegment = ( segment: | { type: "text"; text: string } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 8bfb0e599ad..5b8cca394ba 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -600,6 +600,7 @@ describe("wsApi", () => { it("reads and writes persistence through the desktop bridge when available", async () => { const clientSettings = { autoOpenPlanSidebar: false, + composerSubmitKeybinding: "shiftEnter" as const, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], @@ -663,6 +664,7 @@ describe("wsApi", () => { const api = createLocalApi(rpcClientMock as never); const clientSettings = { autoOpenPlanSidebar: false, + composerSubmitKeybinding: "shiftEnter" as const, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 39695fe3b01..b94aeb7dfc1 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -2,11 +2,40 @@ import { describe, expect, it } from "vitest"; import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; -import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; +import { + ClientSettingsSchema, + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + ServerSettings, + ServerSettingsPatch, +} from "./settings.ts"; const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); + +describe("ClientSettings.composerSubmitKeybinding", () => { + it("defaults to Enter send", () => { + expect(DEFAULT_CLIENT_SETTINGS.composerSubmitKeybinding).toBe("enter"); + expect(decodeClientSettings({}).composerSubmitKeybinding).toBe("enter"); + }); + + it("accepts all composer submit shortcuts", () => { + for (const composerSubmitKeybinding of [ + "enter", + "shiftEnter", + "metaEnter", + "altEnter", + "ctrlEnter", + "buttonOnly", + ]) { + expect(decodeClientSettings({ composerSubmitKeybinding }).composerSubmitKeybinding).toBe( + composerSubmitKeybinding, + ); + } + }); +}); describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..72d5526ed4a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -39,8 +39,22 @@ export const SidebarThreadPreviewCount = Schema.Int.check( export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +export const ComposerSubmitKeybinding = Schema.Literals([ + "enter", + "shiftEnter", + "metaEnter", + "altEnter", + "ctrlEnter", + "buttonOnly", +]); +export type ComposerSubmitKeybinding = typeof ComposerSubmitKeybinding.Type; +export const DEFAULT_COMPOSER_SUBMIT_KEYBINDING: ComposerSubmitKeybinding = "enter"; + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + composerSubmitKeybinding: ComposerSubmitKeybinding.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_COMPOSER_SUBMIT_KEYBINDING)), + ), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -476,6 +490,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), + composerSubmitKeybinding: Schema.optionalKey(ComposerSubmitKeybinding), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), From a7ea75dc2ab12ad45e6ebcbf66bb9baf1f26c8cc Mon Sep 17 00:00:00 2001 From: Brian Coleman Date: Sat, 9 May 2026 22:52:27 -0600 Subject: [PATCH 2/2] Rename composer submit state to send state - Rename deriveComposerSubmitState to deriveComposerSendState - Update ChatComposer call sites and related test names --- .../web/src/components/ChatView.logic.test.ts | 8 ++++---- apps/web/src/components/ChatView.logic.ts | 2 +- apps/web/src/components/ChatView.tsx | 4 ++-- apps/web/src/components/chat/ChatComposer.tsx | 20 +++++++++---------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0f2cc268726..83c90edaddc 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -15,7 +15,7 @@ import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, - deriveComposerSubmitState, + deriveComposerSendState, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, resolveSendEnvMode, @@ -25,9 +25,9 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); -describe("deriveComposerSubmitState", () => { +describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { - const state = deriveComposerSubmitState({ + const state = deriveComposerSendState({ prompt: "\uFFFC", imageCount: 0, terminalContexts: [ @@ -51,7 +51,7 @@ describe("deriveComposerSubmitState", () => { }); it("keeps text sendable while excluding expired terminal pills", () => { - const state = deriveComposerSubmitState({ + const state = deriveComposerSendState({ prompt: `yoo \uFFFC waddup`, imageCount: 0, terminalContexts: [ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 628b797e4df..bf87add28d9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -180,7 +180,7 @@ export function cloneComposerImageForRetry( } } -export function deriveComposerSubmitState(options: { +export function deriveComposerSendState(options: { prompt: string; imageCount: number; terminalContexts: ReadonlyArray; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3d68bf9ae2b..6b84aa11ca6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -156,7 +156,7 @@ import { buildLocalDraftThread, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, - deriveComposerSubmitState, + deriveComposerSendState, hasServerAcknowledgedLocalDispatch, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -2641,7 +2641,7 @@ export default function ChatView(props: ChatViewProps) { sendableTerminalContexts: sendableComposerTerminalContexts, expiredTerminalContextCount, hasSendableContent, - } = deriveComposerSubmitState({ + } = deriveComposerSendState({ prompt: promptForSend, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7ea3ad61df0..093c5c2b298 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -42,7 +42,7 @@ import { replaceTextRange, shouldSubmitComposerOnEnter, } from "../../composer-logic"; -import { deriveComposerSubmitState, readFileAsDataUrl } from "../ChatView.logic"; +import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { type ComposerImageAttachment, type DraftId, @@ -818,11 +818,11 @@ export const ChatComposer = memo( const dragDepthRef = useRef(0); // ------------------------------------------------------------------ - // Derived: composer submit state + // Derived: composer send state // ------------------------------------------------------------------ - const composerSubmitState = useMemo( + const composerSendState = useMemo( () => - deriveComposerSubmitState({ + deriveComposerSendState({ prompt, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, @@ -974,11 +974,11 @@ export const ChatComposer = memo( if (showPlanFollowUpPrompt) { return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; } - return `idle:${composerSubmitState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; + return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; }, [ activePendingIsResponding, activePendingProgress, - composerSubmitState.hasSendableContent, + composerSendState.hasSendableContent, isConnecting, isPreparingWorktree, isSendBusy, @@ -1054,7 +1054,7 @@ export const ChatComposer = memo( [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], ); const collapsedComposerPrimaryActionDisabled = - phase === "running" || isSendBusy || isConnecting || !composerSubmitState.hasSendableContent; + phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; @@ -1606,11 +1606,11 @@ export const ChatComposer = memo( if (activePendingProgress) { return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } - return showPlanFollowUpPrompt || composerSubmitState.hasSendableContent; + return showPlanFollowUpPrompt || composerSendState.hasSendableContent; }, [ activePendingProgress, activePendingResolvedAnswers, - composerSubmitState.hasSendableContent, + composerSendState.hasSendableContent, isConnecting, isMobileViewport, isSendBusy, @@ -2407,7 +2407,7 @@ export const ChatComposer = memo( isConnecting={isConnecting} isEnvironmentUnavailable={environmentUnavailable !== null} isPreparingWorktree={isPreparingWorktree} - hasSendableContent={composerSubmitState.hasSendableContent} + hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction}