From ec8dc3456c1696a5eba6c37b6e26ef99837c35e2 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 9 Jul 2026 23:37:10 +0800 Subject: [PATCH] fix(web): stop sending prompts into a busy turn on the web UI (#1522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): prevent duplicate first prompts and keep goal drives from looking idle - Guard startSessionAndSendPrompt with a per-workspace reentry lock so a double-click / repeated Enter during draft-session creation cannot fire two concurrent first prompts into the same new session. - Track goal.active in the agent event projector so turn.ended between goal-driven continuation turns keeps the session 'running' instead of projecting a false 'idle' that drains the local queue into a still-busy core (turn.agent_busy). - Show a 'starting conversation…' loading state on the empty-session landing while the first prompt is being created and submitted. - Persist the resolved model in startSessionAndActivateSkill so the first skill turn on a fresh session does not fail with 'Model not set'. * chore: add changeset for web first-prompt fixes * fix(web): close remaining first-prompt and goal-settle gaps - Pass the starting guard through the dock composer: draft-session creation selects the new session before submit, which swaps the empty composer for the dock; disabling both composers closes the last path to a concurrent first POST. Also take the workspace lock in startSessionAndActivateSkill / startSessionAndOpenSideChat. - Emit the owed idle when a goal settles (blocked/paused/completed) in the inter-turn gap after a turn.ended was projected as 'running', so sending state, in-flight flags and queued prompts flush instead of the session staying 'running' forever. * style(web): fix eqeqeq lint error in first-prompt guard * fix(web): clear owed idle when a new goal turn starts The idle debt from a 'running' projection survived turn.started, so an UpdateGoal('complete'|'blocked') landing mid-turn in the NEXT goal turn synthesized an early idle. onSessionIdle could then drain queued prompts into a core that was mid-turn again, re-opening the turn.agent_busy race for multi-turn goals. Clear the debt on turn.started: from that point the turn's own turn.ended carries the idle with goalActive already false. * fix(web): make first-prompt starting state workspace-id-agnostic isStartingFirstPrompt now reads from the lock set directly (size > 0) instead of the current activeWorkspaceId. createDraftSession can swap activeWorkspaceId to a registered id mid-flight; a workspace-keyed read would then return false while the first prompt is still in the create/ select/submit window, re-enabling the composer and reopening the duplicate first-submit race. * revert(web): drop goal-aware idle projection from agentEventProjector The goalActive / idleOwed shadow state machine grew through multiple review rounds and still leaves edge cases (snapshot-seeded turns, mid- turn goal updates). Roll it back to the simple 'turn.ended projects idle' behavior. Goal-driven sessions can once again race a queued prompt into a busy core; this is accepted as a known limitation to be resolved properly in a follow-up that has the core emit an authoritative idle signal. * chore: align changeset with actual fix scope * test(web): update profile-patch expectation for model field --- .changeset/web-first-prompt-fixes.md | 5 +++ apps/kimi-web/src/App.vue | 1 + .../kimi-web/src/components/chat/ChatDock.vue | 5 +++ .../kimi-web/src/components/chat/Composer.vue | 41 +++++++++++++++--- .../src/components/chat/ConversationPane.vue | 27 +++++++++--- .../composables/client/useWorkspaceState.ts | 42 +++++++++++++++++++ .../src/composables/useKimiWebClient.ts | 6 +++ apps/kimi-web/src/i18n/locales/en/composer.ts | 1 + .../src/i18n/locales/en/conversation.ts | 1 + apps/kimi-web/src/i18n/locales/zh/composer.ts | 1 + .../src/i18n/locales/zh/conversation.ts | 1 + apps/kimi-web/test/workspace-state.test.ts | 2 +- 12 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 .changeset/web-first-prompt-fixes.md diff --git a/.changeset/web-first-prompt-fixes.md b/.changeset/web-first-prompt-fixes.md new file mode 100644 index 000000000..fa34159ab --- /dev/null +++ b/.changeset/web-first-prompt-fixes.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix an occasional "another turn is active" error when sending the first message of a new conversation, and show a starting state while it is being sent. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index e46e8a2fc..26dfcaefc 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -739,6 +739,7 @@ function openPr(url: string): void { :search-files="client.searchFiles" :upload-image="client.uploadImage" :sending="client.isSending.value" + :starting="client.isStartingFirstPrompt.value" :fast-moon="client.fastMoon.value" :file-reload-key="client.activeSessionId.value" :session-loading="client.sessionLoading.value" diff --git a/apps/kimi-web/src/components/chat/ChatDock.vue b/apps/kimi-web/src/components/chat/ChatDock.vue index 8da9067d2..ab84330a3 100644 --- a/apps/kimi-web/src/components/chat/ChatDock.vue +++ b/apps/kimi-web/src/components/chat/ChatDock.vue @@ -20,6 +20,10 @@ import Pill from '../ui/Pill.vue'; const props = defineProps<{ sessionId?: string; running?: boolean; + /** True while the empty-composer first prompt is being created + submitted. + * Covers the gap where draft-session creation already selected the new + * session (empty state → dock) before the first prompt is submitted. */ + starting?: boolean; queued?: QueuedPromptView[]; searchFiles?: (q: string) => Promise; uploadImage?: (file: Blob, name?: string) => Promise<{ fileId: string; name: string; mediaType: string } | null>; @@ -256,6 +260,7 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus }); :models="models" :starred-ids="starredIds" :skills="skills" + :starting="starting" @submit="emit('submit', $event)" @steer="emit('steer', $event)" @command="emit('command', $event)" diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 92031302e..2b3968788 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -35,6 +35,9 @@ import Tooltip from '../ui/Tooltip.vue'; const props = withDefaults(defineProps<{ running?: boolean; + /** True while the empty-composer first prompt is being created + submitted. + * Disables the textarea and swaps the send button for a spinner. */ + starting?: boolean; /** Active session id — scopes the persisted unsent draft (per session). */ sessionId?: string; queued?: QueuedPromptView[]; @@ -59,6 +62,7 @@ const props = withDefaults(defineProps<{ hideContext?: boolean; }>(), { running: false, + starting: false, queued: () => [], searchFiles: undefined, uploadImage: undefined, @@ -68,11 +72,13 @@ const props = withDefaults(defineProps<{ }); const placeholder = computed(() => - props.running - ? t('composer.placeholderRunning') - : props.goalMode - ? t('status.goalPlaceholder') - : t('composer.placeholder') + props.starting + ? t('composer.starting') + : props.running + ? t('composer.placeholderRunning') + : props.goalMode + ? t('status.goalPlaceholder') + : t('composer.placeholder') ); const emit = defineEmits<{ @@ -913,6 +919,7 @@ function selectModel(modelId: string): void { v-model="text" class="ph" :placeholder="placeholder" + :disabled="starting" rows="1" @keydown="handleKeydown" @compositionstart="handleCompositionStart" @@ -1131,10 +1138,13 @@ function selectModel(modelId: string): void { @@ -1520,6 +1530,25 @@ function selectModel(modelId: string): void { transform: scale(0.92); } +.send:disabled { + cursor: not-allowed; + opacity: 0.88; +} + +.send:disabled:active { + transform: none; +} + +/* Spinner-on-accent: recolor the ring so the arc reads on the accent fill. + Spinner.vue styles are scoped, so pierce them with :deep(). */ +.send.is-starting :deep(.ui-spinner) { + color: var(--color-text-on-accent); +} + +.send.is-starting :deep(.ui-spinner__track) { + stroke: rgba(255, 255, 255, 0.32); +} + .send svg { flex: none; width: var(--p-ic-lg); diff --git a/apps/kimi-web/src/components/chat/ConversationPane.vue b/apps/kimi-web/src/components/chat/ConversationPane.vue index 88fdc5dce..7c7f47511 100644 --- a/apps/kimi-web/src/components/chat/ConversationPane.vue +++ b/apps/kimi-web/src/components/chat/ConversationPane.vue @@ -12,6 +12,7 @@ import Composer from './Composer.vue'; import ChatDock from './ChatDock.vue'; import ConversationToc, { type ConversationTocItem } from './ConversationToc.vue'; import Icon from '../ui/Icon.vue'; +import Spinner from '../ui/Spinner.vue'; import Tooltip from '../ui/Tooltip.vue'; import { getVisibleWorkspaces } from '../../lib/workspacePicker'; import { safeRemove, STORAGE_KEYS } from '../../lib/storage'; @@ -48,6 +49,9 @@ const props = defineProps<{ /** Cache-buster that remounts the chat pane when the active session changes. */ fileReloadKey?: string | number; sending?: boolean; + /** True while the empty-composer first prompt is being created + submitted. + * Drives the empty-session "starting conversation…" loading state. */ + starting?: boolean; fastMoon?: boolean; /** Mobile shell: compact chrome. */ mobile?: boolean; @@ -1134,10 +1138,14 @@ defineExpose({ loadComposerForEdit, focusComposer });
- {{ t('composer.emptyConversationTitle') }} - {{ t('composer.emptyConversation') }} - -
+ + + {{ starting ? t('conversation.starting') : t('composer.emptyConversationTitle') }} + + {{ t('composer.emptyConversation') }} + +