fix(web): only block submit during real env prepare, not every STARTING

The "agent still being set up" submit gate was triggering on every
session that briefly transitions through STARTING — including local
quick-chat, which has no prepare phase and has its agent live within
~100ms. The eager-init quick-chat e2e tests (chat/quick-chat.spec.ts
and chat/message-queue.spec.ts) wait for the editor to become
editable, then press Cmd+Enter. The race made wrappedSubmit a no-op
exactly when the test was firing — message dropped, agent never saw
it, downstream assertions failed.

Split the merged isStarting flag in useSessionState: keep the broad
flag (still used for the workspace-prep loading UI), and expose
isPreparingEnvironment as a separate signal that's only true while
prepareProgress.status === "preparing". Thread it through chat-input-
area → chat-input-container → useChatInputContainer and gate the
submit-disabled tooltip on it instead of the broad isStarting.

Net: Docker / Sprites bootstrap still surfaces the "agent still being
set up" message and disables submit; quick-chat / local sessions stop
silently dropping in-flight messages.
This commit is contained in:
José Almeida
2026-05-10 15:00:41 +01:00
parent 502358f7ea
commit 7851052634
5 changed files with 35 additions and 22 deletions
@@ -413,6 +413,7 @@ export function ChatInputArea({
onPlanModeChange={handlePlanModeChange}
isAgentBusy={isAgentBusy}
isStarting={panelState.isStarting}
isPreparingEnvironment={panelState.isPreparingEnvironment}
isMoving={isMoving}
isSending={isSending}
onCancel={handleCancelTurn}
@@ -57,6 +57,11 @@ type ChatInputContainerProps = {
onPlanModeChange: (enabled: boolean) => void;
isAgentBusy: boolean;
isStarting: boolean;
/** True only while a containerized executor is bootstrapping (Docker
* prepare, Sprites sandbox spin-up). Distinct from the brief STARTING
* state every session — including local quick-chat — passes through;
* see useSessionState. */
isPreparingEnvironment?: boolean;
isMoving?: boolean;
isSending: boolean;
onCancel: () => void;
@@ -292,6 +297,7 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
sessionId,
isSending,
isStarting,
isPreparingEnvironment: props.isPreparingEnvironment ?? false,
isMoving,
isFailed: p.isFailed,
needsRecovery: props.needsRecovery ?? false,
@@ -11,6 +11,7 @@ function renderInputState(overrides: Partial<Parameters<typeof useChatInputConta
sessionId: "session-1",
isSending: false,
isStarting: false,
isPreparingEnvironment: false,
isMoving: false,
isFailed: false,
needsRecovery: false,
@@ -16,6 +16,11 @@ type UseChatInputContainerParams = {
sessionId: string | null;
isSending: boolean;
isStarting: boolean;
/** True only during a real Docker/Sprites prepare phase. Different from
* `isStarting`, which fires for every session that's transitioning
* through STARTING (including local quick-chat). Submit gating uses
* this narrower flag so quick-chat sessions aren't blocked. */
isPreparingEnvironment: boolean;
isMoving: boolean;
isFailed: boolean;
needsRecovery: boolean;
@@ -82,6 +87,7 @@ function getInputPlaceholder(
function computeDerivedState(params: {
isStarting: boolean;
isPreparingEnvironment: boolean;
isMoving: boolean;
isSending: boolean;
isFailed: boolean;
@@ -102,8 +108,15 @@ function computeDerivedState(params: {
params.isFailed ||
params.needsRecovery ||
params.executorUnavailable;
const submitDisabled = params.isStarting || isDisabled;
const submitDisabledReason = params.isStarting ? "The agent is still being set up." : undefined;
// Only an active container/sandbox prepare blocks the submit and surfaces
// the "agent still being set up" tooltip. The brief STARTING transition
// every session passes through (including local quick-chat) intentionally
// does NOT block — the e2e suite presses Cmd+Enter as soon as the editor
// becomes editable, and a STARTING-only block silently dropped messages.
const submitDisabled = params.isPreparingEnvironment || isDisabled;
const submitDisabledReason = params.isPreparingEnvironment
? "The agent is still being set up."
: undefined;
const hasClarification = !!(params.pendingClarification && params.onClarificationResolved);
const hasPendingComments = !!(
params.pendingCommentsByFile && Object.keys(params.pendingCommentsByFile).length > 0
@@ -129,26 +142,11 @@ function computeDerivedState(params: {
}
export function useChatInputContainer(params: UseChatInputContainerParams) {
const {
ref,
sessionId,
isSending,
isStarting,
isMoving,
isFailed,
needsRecovery,
executorUnavailable,
isAgentBusy,
hasAgentCommands,
placeholder,
contextItems,
pendingClarification,
onClarificationResolved,
pendingCommentsByFile,
showRequestChangesTooltip,
onRequestChangesTooltipDismiss,
onSubmit,
} = params;
const { ref, sessionId, isSending, isStarting, isPreparingEnvironment, isMoving } = params;
const { isFailed, needsRecovery, executorUnavailable, isAgentBusy, hasAgentCommands } = params;
const { placeholder, contextItems, pendingClarification, onClarificationResolved } = params;
const { pendingCommentsByFile, showRequestChangesTooltip } = params;
const { onRequestChangesTooltipDismiss, onSubmit } = params;
const [isInputFocused, setIsInputFocused] = useState(false);
const [showNewSessionDialog, setShowNewSessionDialog] = useState(false);
@@ -202,6 +200,7 @@ export function useChatInputContainer(params: UseChatInputContainerParams) {
const derived = computeDerivedState({
isStarting,
isPreparingEnvironment,
isMoving,
isSending,
isFailed,
@@ -48,5 +48,11 @@ export function useSessionState(sessionId: string | null) {
...flags,
isStarting: flags.isStarting || isPreparingEnvironment,
isWorking: flags.isWorking || isPreparingEnvironment,
// Exposed separately so consumers that gate on "is the executor still
// bootstrapping" (e.g. the chat input "agent is still being set up"
// tooltip) can distinguish a brief STARTING transition — which every
// session passes through, including local quick-chat — from an active
// Docker / Sprites prepare-environment phase.
isPreparingEnvironment,
};
}