fix(web): stop sending prompts into a busy turn on the web UI (#1522)

* 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
This commit is contained in:
qer 2026-07-09 23:37:10 +08:00 committed by GitHub
parent 046b6c4175
commit ec8dc3456c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 121 additions and 12 deletions

View file

@ -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.

View file

@ -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"

View file

@ -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<FileItem[]>;
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)"

View file

@ -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 {
</Tooltip>
<button
class="send"
:class="{ 'is-starting': starting }"
:aria-label="sendLabel"
:disabled="starting"
@click="handleSubmit()"
>
<Icon name="send" size="sm" />
<Spinner v-if="starting" size="sm" />
<Icon v-else name="send" size="sm" />
</button>
</div>
@ -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);

View file

@ -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 });
<!-- Empty session: Composer rendered in the centre of the pane -->
<div class="empty-spacer" />
<div class="empty-hint">
<span class="empty-hint-title">{{ t('composer.emptyConversationTitle') }}</span>
<span class="empty-hint-text">{{ t('composer.emptyConversation') }}</span>
<!-- Workspace picker: choose where this new conversation starts. -->
<div v-if="hasWorkspaces" class="ws-pick" :style="wsPickStyle">
<span class="empty-hint-title" :class="{ 'is-starting': starting }">
<Spinner v-if="starting" size="sm" />
<span>{{ starting ? t('conversation.starting') : t('composer.emptyConversationTitle') }}</span>
</span>
<span v-if="!starting" class="empty-hint-text">{{ t('composer.emptyConversation') }}</span>
<!-- Workspace picker: choose where this new conversation starts.
Hidden while starting a workspace is already committed. -->
<div v-if="hasWorkspaces && !starting" class="ws-pick" :style="wsPickStyle">
<div ref="wsPickMeasureRef" class="ws-pick-measure" aria-hidden="true">
<button type="button" class="ws-pick-btn" tabindex="-1">
<Icon name="folder" size="sm" />
@ -1198,7 +1206,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
</div>
</div>
<button
v-else
v-else-if="!starting"
type="button"
class="empty-add-workspace"
@click="emit('addWorkspace')"
@ -1225,6 +1233,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
:models="models"
:starred-ids="starredIds"
:skills="skills"
:starting="starting"
hide-context
@submit="handleComposerSubmit"
@steer="emit('steer', $event)"
@ -1286,6 +1295,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
:style="chatDockStyle"
:session-id="sessionId"
:running="running"
:starting="starting"
:queued="queued"
:search-files="searchFiles"
:upload-image="uploadImage"
@ -1449,6 +1459,13 @@ defineExpose({ loadComposerForEdit, focusComposer });
font-optical-sizing: auto;
font-weight: 600;
}
.empty-hint-title.is-starting {
display: inline-flex;
align-items: center;
gap: 9px;
color: var(--dim);
font-weight: 400;
}
.empty-hint-text {
display: inline-block;
font-size: var(--text-base);

View file

@ -82,6 +82,17 @@ const pendingQuestionActions = reactive<Record<string, 'answer' | 'dismiss'>>({}
const pendingApprovalActions = reactive<Record<string, true>>({});
/** Task ids with an in-flight cancel, keyed by taskId. */
const pendingTaskCancellations = reactive<Record<string, true>>({});
/**
* Workspace ids whose empty-session first prompt is currently being created +
* submitted. The empty-composer path (`startSessionAndSendPrompt`) awaits
* `createDraftSession` (addWorkspace + createSession + selectSession) before
* the session id exists, so the per-session `inFlightPromptSessions` guard
* cannot cover that window a second Enter / send-button click during it
* would otherwise fire a second concurrent POST and trip the daemon's
* `turn.agent_busy` race. Module-level singleton matches the other
* `pending*Actions` guards above.
*/
const startingFirstPromptWorkspaces = reactive(new Set<string>());
type SyncSessionResult = 'ok' | 'not-found' | 'failed';
@ -815,12 +826,22 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
text: string,
attachments?: PromptAttachment[],
): Promise<void> {
// Guard the whole "create draft session + submit first prompt" flow: the
// session id doesn't exist until `createDraftSession` resolves, so the
// per-session `inFlightPromptSessions` guard can't cover this window. A
// second Enter / send-button click in that window would otherwise fire a
// concurrent first POST for the same new session and trip the daemon's
// `turn.agent_busy` race.
if (startingFirstPromptWorkspaces.has(workspaceId)) return;
startingFirstPromptWorkspaces.add(workspaceId);
try {
const sid = await createDraftSession(workspaceId);
if (!sid) return;
await submitPromptInternal(sid, text, attachments);
} catch (err) {
pushOperationFailure('startSessionAndSendPrompt', err);
} finally {
startingFirstPromptWorkspaces.delete(workspaceId);
}
}
@ -837,6 +858,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
skillName: string,
args?: string,
): Promise<void> {
// Same reentry window as startSessionAndSendPrompt (see the guard there):
// draft-session creation selects the new session before the activation,
// so concurrent first actions must be dropped here.
if (startingFirstPromptWorkspaces.has(workspaceId)) return;
startingFirstPromptWorkspaces.add(workspaceId);
try {
const sid = await createDraftSession(workspaceId);
if (!sid) return;
@ -862,6 +888,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
: rawState.defaultModel) ?? undefined;
await persistSessionProfile(
{
model,
planMode,
swarmMode,
permissionMode: rawState.permission,
@ -872,6 +899,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
await modelProvider.activateSkill(skillName, args, sid);
} catch (err) {
pushOperationFailure('startSessionAndActivateSkill', err);
} finally {
startingFirstPromptWorkspaces.delete(workspaceId);
}
}
@ -888,12 +917,17 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
workspaceId: string,
prompt?: string,
): Promise<void> {
// Same reentry window as startSessionAndSendPrompt (see the guard there).
if (startingFirstPromptWorkspaces.has(workspaceId)) return;
startingFirstPromptWorkspaces.add(workspaceId);
try {
const sid = await createDraftSession(workspaceId);
if (!sid) return;
await sideChat.openSideChatOn(sid, prompt);
} catch (err) {
pushOperationFailure('startSessionAndOpenSideChat', err);
} finally {
startingFirstPromptWorkspaces.delete(workspaceId);
}
}
@ -2186,6 +2220,14 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
searchFiles,
loadOlderMessages,
refreshSessionSidecars,
/** True while any empty-composer first prompt is being created + submitted
* (the window covered by startingFirstPromptWorkspaces). Drives the
* empty-session "starting conversation…" loading state. Intentionally
* keyed by the lock set itself rather than the current activeWorkspaceId:
* createDraftSession can swap activeWorkspaceId to a registered id
* mid-flight, and a workspace-keyed read would prematurely re-enable the
* composer and reopen the duplicate first-submit race. */
isStartingFirstPrompt: () => startingFirstPromptWorkspaces.size > 0,
};
}

View file

@ -1685,6 +1685,11 @@ const isSending = computed<boolean>(() => {
return rawState.sendingBySession[sid] ?? false;
});
// True while the empty-composer first prompt for the active workspace is being
// created + submitted (before the session id exists). Drives the empty-session
// "starting conversation…" loading state in ConversationPane / Composer.
const isStartingFirstPrompt = computed<boolean>(() => workspaceState.isStartingFirstPrompt());
const sideChat = useSideChat(rawState, {
pushOperationFailure,
nextOptimisticMsgId,
@ -2520,6 +2525,7 @@ export function useKimiWebClient() {
questions,
activity,
isSending,
isStartingFirstPrompt,
fastMoon: appearance.fastMoon,
// Model + Provider reactive state

View file

@ -3,6 +3,7 @@ export default {
send: 'Send ↵',
queueLabel: 'Queue',
placeholderRunning: 'Press Enter to queue · Ctrl+S to inject into the running turn',
starting: 'Sending…',
queueAutoDrain: 'sends automatically when the current turn ends',
queueNext: 'Up next',
queueDragTitle: 'Drag to reorder',

View file

@ -3,6 +3,7 @@ export default {
toc: 'Conversation outline',
newMessages: 'Latest messages',
loading: 'Loading…',
starting: 'Starting conversation…',
emptyWorkspaceHint: 'Send in {name}',
switchWorkspace: 'Switch workspace',
addWorkspace: 'New workspace',

View file

@ -3,6 +3,7 @@ export default {
send: '发送 ↵',
queueLabel: '队列',
placeholderRunning: '输入会加入队列 · Ctrl+S 立即插入运行中的回合',
starting: '正在发送…',
queueAutoDrain: '当前回合结束后自动逐条发送',
queueNext: '下一条',
queueDragTitle: '拖拽排序',

View file

@ -3,6 +3,7 @@ export default {
toc: '对话目录',
newMessages: '最新消息',
loading: '加载中…',
starting: '正在创建对话…',
emptyWorkspaceHint: '在 {name} 中发送',
switchWorkspace: '切换工作区',
addWorkspace: '添加工作区',

View file

@ -638,7 +638,7 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => {
// Activation must NOT have started while /profile is still pending.
await new Promise((r) => setTimeout(r, 0));
expect(persistSessionProfile).toHaveBeenCalledWith(
{ planMode: true, swarmMode: true, permissionMode: 'auto', thinking: 'high' },
{ model: undefined, planMode: true, swarmMode: true, permissionMode: 'auto', thinking: 'high' },
'sess_new',
);
expect(activateSkill).not.toHaveBeenCalled();