Skip to content

Commit ec8dc34

Browse files
authored
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
1 parent 046b6c4 commit ec8dc34

12 files changed

Lines changed: 121 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
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.

apps/kimi-web/src/App.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,7 @@ function openPr(url: string): void {
739739
:search-files="client.searchFiles"
740740
:upload-image="client.uploadImage"
741741
:sending="client.isSending.value"
742+
:starting="client.isStartingFirstPrompt.value"
742743
:fast-moon="client.fastMoon.value"
743744
:file-reload-key="client.activeSessionId.value"
744745
:session-loading="client.sessionLoading.value"

apps/kimi-web/src/components/chat/ChatDock.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import Pill from '../ui/Pill.vue';
2020
const props = defineProps<{
2121
sessionId?: string;
2222
running?: boolean;
23+
/** True while the empty-composer first prompt is being created + submitted.
24+
* Covers the gap where draft-session creation already selected the new
25+
* session (empty state → dock) before the first prompt is submitted. */
26+
starting?: boolean;
2327
queued?: QueuedPromptView[];
2428
searchFiles?: (q: string) => Promise<FileItem[]>;
2529
uploadImage?: (file: Blob, name?: string) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
@@ -256,6 +260,7 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
256260
:models="models"
257261
:starred-ids="starredIds"
258262
:skills="skills"
263+
:starting="starting"
259264
@submit="emit('submit', $event)"
260265
@steer="emit('steer', $event)"
261266
@command="emit('command', $event)"

apps/kimi-web/src/components/chat/Composer.vue

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ import Tooltip from '../ui/Tooltip.vue';
3535
3636
const props = withDefaults(defineProps<{
3737
running?: boolean;
38+
/** True while the empty-composer first prompt is being created + submitted.
39+
* Disables the textarea and swaps the send button for a spinner. */
40+
starting?: boolean;
3841
/** Active session id — scopes the persisted unsent draft (per session). */
3942
sessionId?: string;
4043
queued?: QueuedPromptView[];
@@ -59,6 +62,7 @@ const props = withDefaults(defineProps<{
5962
hideContext?: boolean;
6063
}>(), {
6164
running: false,
65+
starting: false,
6266
queued: () => [],
6367
searchFiles: undefined,
6468
uploadImage: undefined,
@@ -68,11 +72,13 @@ const props = withDefaults(defineProps<{
6872
});
6973
7074
const placeholder = computed(() =>
71-
props.running
72-
? t('composer.placeholderRunning')
73-
: props.goalMode
74-
? t('status.goalPlaceholder')
75-
: t('composer.placeholder')
75+
props.starting
76+
? t('composer.starting')
77+
: props.running
78+
? t('composer.placeholderRunning')
79+
: props.goalMode
80+
? t('status.goalPlaceholder')
81+
: t('composer.placeholder')
7682
);
7783
7884
const emit = defineEmits<{
@@ -913,6 +919,7 @@ function selectModel(modelId: string): void {
913919
v-model="text"
914920
class="ph"
915921
:placeholder="placeholder"
922+
:disabled="starting"
916923
rows="1"
917924
@keydown="handleKeydown"
918925
@compositionstart="handleCompositionStart"
@@ -1131,10 +1138,13 @@ function selectModel(modelId: string): void {
11311138
</Tooltip>
11321139
<button
11331140
class="send"
1141+
:class="{ 'is-starting': starting }"
11341142
:aria-label="sendLabel"
1143+
:disabled="starting"
11351144
@click="handleSubmit()"
11361145
>
1137-
<Icon name="send" size="sm" />
1146+
<Spinner v-if="starting" size="sm" />
1147+
<Icon v-else name="send" size="sm" />
11381148
</button>
11391149
</div>
11401150

@@ -1520,6 +1530,25 @@ function selectModel(modelId: string): void {
15201530
transform: scale(0.92);
15211531
}
15221532
1533+
.send:disabled {
1534+
cursor: not-allowed;
1535+
opacity: 0.88;
1536+
}
1537+
1538+
.send:disabled:active {
1539+
transform: none;
1540+
}
1541+
1542+
/* Spinner-on-accent: recolor the ring so the arc reads on the accent fill.
1543+
Spinner.vue styles are scoped, so pierce them with :deep(). */
1544+
.send.is-starting :deep(.ui-spinner) {
1545+
color: var(--color-text-on-accent);
1546+
}
1547+
1548+
.send.is-starting :deep(.ui-spinner__track) {
1549+
stroke: rgba(255, 255, 255, 0.32);
1550+
}
1551+
15231552
.send svg {
15241553
flex: none;
15251554
width: var(--p-ic-lg);

apps/kimi-web/src/components/chat/ConversationPane.vue

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Composer from './Composer.vue';
1212
import ChatDock from './ChatDock.vue';
1313
import ConversationToc, { type ConversationTocItem } from './ConversationToc.vue';
1414
import Icon from '../ui/Icon.vue';
15+
import Spinner from '../ui/Spinner.vue';
1516
import Tooltip from '../ui/Tooltip.vue';
1617
import { getVisibleWorkspaces } from '../../lib/workspacePicker';
1718
import { safeRemove, STORAGE_KEYS } from '../../lib/storage';
@@ -48,6 +49,9 @@ const props = defineProps<{
4849
/** Cache-buster that remounts the chat pane when the active session changes. */
4950
fileReloadKey?: string | number;
5051
sending?: boolean;
52+
/** True while the empty-composer first prompt is being created + submitted.
53+
* Drives the empty-session "starting conversation…" loading state. */
54+
starting?: boolean;
5155
fastMoon?: boolean;
5256
/** Mobile shell: compact chrome. */
5357
mobile?: boolean;
@@ -1134,10 +1138,14 @@ defineExpose({ loadComposerForEdit, focusComposer });
11341138
<!-- Empty session: Composer rendered in the centre of the pane -->
11351139
<div class="empty-spacer" />
11361140
<div class="empty-hint">
1137-
<span class="empty-hint-title">{{ t('composer.emptyConversationTitle') }}</span>
1138-
<span class="empty-hint-text">{{ t('composer.emptyConversation') }}</span>
1139-
<!-- Workspace picker: choose where this new conversation starts. -->
1140-
<div v-if="hasWorkspaces" class="ws-pick" :style="wsPickStyle">
1141+
<span class="empty-hint-title" :class="{ 'is-starting': starting }">
1142+
<Spinner v-if="starting" size="sm" />
1143+
<span>{{ starting ? t('conversation.starting') : t('composer.emptyConversationTitle') }}</span>
1144+
</span>
1145+
<span v-if="!starting" class="empty-hint-text">{{ t('composer.emptyConversation') }}</span>
1146+
<!-- Workspace picker: choose where this new conversation starts.
1147+
Hidden while starting — a workspace is already committed. -->
1148+
<div v-if="hasWorkspaces && !starting" class="ws-pick" :style="wsPickStyle">
11411149
<div ref="wsPickMeasureRef" class="ws-pick-measure" aria-hidden="true">
11421150
<button type="button" class="ws-pick-btn" tabindex="-1">
11431151
<Icon name="folder" size="sm" />
@@ -1198,7 +1206,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
11981206
</div>
11991207
</div>
12001208
<button
1201-
v-else
1209+
v-else-if="!starting"
12021210
type="button"
12031211
class="empty-add-workspace"
12041212
@click="emit('addWorkspace')"
@@ -1225,6 +1233,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
12251233
:models="models"
12261234
:starred-ids="starredIds"
12271235
:skills="skills"
1236+
:starting="starting"
12281237
hide-context
12291238
@submit="handleComposerSubmit"
12301239
@steer="emit('steer', $event)"
@@ -1286,6 +1295,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
12861295
:style="chatDockStyle"
12871296
:session-id="sessionId"
12881297
:running="running"
1298+
:starting="starting"
12891299
:queued="queued"
12901300
:search-files="searchFiles"
12911301
:upload-image="uploadImage"
@@ -1449,6 +1459,13 @@ defineExpose({ loadComposerForEdit, focusComposer });
14491459
font-optical-sizing: auto;
14501460
font-weight: 600;
14511461
}
1462+
.empty-hint-title.is-starting {
1463+
display: inline-flex;
1464+
align-items: center;
1465+
gap: 9px;
1466+
color: var(--dim);
1467+
font-weight: 400;
1468+
}
14521469
.empty-hint-text {
14531470
display: inline-block;
14541471
font-size: var(--text-base);

apps/kimi-web/src/composables/client/useWorkspaceState.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ const pendingQuestionActions = reactive<Record<string, 'answer' | 'dismiss'>>({}
8282
const pendingApprovalActions = reactive<Record<string, true>>({});
8383
/** Task ids with an in-flight cancel, keyed by taskId. */
8484
const pendingTaskCancellations = reactive<Record<string, true>>({});
85+
/**
86+
* Workspace ids whose empty-session first prompt is currently being created +
87+
* submitted. The empty-composer path (`startSessionAndSendPrompt`) awaits
88+
* `createDraftSession` (addWorkspace + createSession + selectSession) before
89+
* the session id exists, so the per-session `inFlightPromptSessions` guard
90+
* cannot cover that window — a second Enter / send-button click during it
91+
* would otherwise fire a second concurrent POST and trip the daemon's
92+
* `turn.agent_busy` race. Module-level singleton — matches the other
93+
* `pending*Actions` guards above.
94+
*/
95+
const startingFirstPromptWorkspaces = reactive(new Set<string>());
8596

8697
type SyncSessionResult = 'ok' | 'not-found' | 'failed';
8798

@@ -815,12 +826,22 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
815826
text: string,
816827
attachments?: PromptAttachment[],
817828
): Promise<void> {
829+
// Guard the whole "create draft session + submit first prompt" flow: the
830+
// session id doesn't exist until `createDraftSession` resolves, so the
831+
// per-session `inFlightPromptSessions` guard can't cover this window. A
832+
// second Enter / send-button click in that window would otherwise fire a
833+
// concurrent first POST for the same new session and trip the daemon's
834+
// `turn.agent_busy` race.
835+
if (startingFirstPromptWorkspaces.has(workspaceId)) return;
836+
startingFirstPromptWorkspaces.add(workspaceId);
818837
try {
819838
const sid = await createDraftSession(workspaceId);
820839
if (!sid) return;
821840
await submitPromptInternal(sid, text, attachments);
822841
} catch (err) {
823842
pushOperationFailure('startSessionAndSendPrompt', err);
843+
} finally {
844+
startingFirstPromptWorkspaces.delete(workspaceId);
824845
}
825846
}
826847

@@ -837,6 +858,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
837858
skillName: string,
838859
args?: string,
839860
): Promise<void> {
861+
// Same reentry window as startSessionAndSendPrompt (see the guard there):
862+
// draft-session creation selects the new session before the activation,
863+
// so concurrent first actions must be dropped here.
864+
if (startingFirstPromptWorkspaces.has(workspaceId)) return;
865+
startingFirstPromptWorkspaces.add(workspaceId);
840866
try {
841867
const sid = await createDraftSession(workspaceId);
842868
if (!sid) return;
@@ -862,6 +888,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
862888
: rawState.defaultModel) ?? undefined;
863889
await persistSessionProfile(
864890
{
891+
model,
865892
planMode,
866893
swarmMode,
867894
permissionMode: rawState.permission,
@@ -872,6 +899,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
872899
await modelProvider.activateSkill(skillName, args, sid);
873900
} catch (err) {
874901
pushOperationFailure('startSessionAndActivateSkill', err);
902+
} finally {
903+
startingFirstPromptWorkspaces.delete(workspaceId);
875904
}
876905
}
877906

@@ -888,12 +917,17 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
888917
workspaceId: string,
889918
prompt?: string,
890919
): Promise<void> {
920+
// Same reentry window as startSessionAndSendPrompt (see the guard there).
921+
if (startingFirstPromptWorkspaces.has(workspaceId)) return;
922+
startingFirstPromptWorkspaces.add(workspaceId);
891923
try {
892924
const sid = await createDraftSession(workspaceId);
893925
if (!sid) return;
894926
await sideChat.openSideChatOn(sid, prompt);
895927
} catch (err) {
896928
pushOperationFailure('startSessionAndOpenSideChat', err);
929+
} finally {
930+
startingFirstPromptWorkspaces.delete(workspaceId);
897931
}
898932
}
899933

@@ -2186,6 +2220,14 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
21862220
searchFiles,
21872221
loadOlderMessages,
21882222
refreshSessionSidecars,
2223+
/** True while any empty-composer first prompt is being created + submitted
2224+
* (the window covered by startingFirstPromptWorkspaces). Drives the
2225+
* empty-session "starting conversation…" loading state. Intentionally
2226+
* keyed by the lock set itself rather than the current activeWorkspaceId:
2227+
* createDraftSession can swap activeWorkspaceId to a registered id
2228+
* mid-flight, and a workspace-keyed read would prematurely re-enable the
2229+
* composer and reopen the duplicate first-submit race. */
2230+
isStartingFirstPrompt: () => startingFirstPromptWorkspaces.size > 0,
21892231
};
21902232
}
21912233

apps/kimi-web/src/composables/useKimiWebClient.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1685,6 +1685,11 @@ const isSending = computed<boolean>(() => {
16851685
return rawState.sendingBySession[sid] ?? false;
16861686
});
16871687

1688+
// True while the empty-composer first prompt for the active workspace is being
1689+
// created + submitted (before the session id exists). Drives the empty-session
1690+
// "starting conversation…" loading state in ConversationPane / Composer.
1691+
const isStartingFirstPrompt = computed<boolean>(() => workspaceState.isStartingFirstPrompt());
1692+
16881693
const sideChat = useSideChat(rawState, {
16891694
pushOperationFailure,
16901695
nextOptimisticMsgId,
@@ -2520,6 +2525,7 @@ export function useKimiWebClient() {
25202525
questions,
25212526
activity,
25222527
isSending,
2528+
isStartingFirstPrompt,
25232529
fastMoon: appearance.fastMoon,
25242530

25252531
// Model + Provider reactive state

apps/kimi-web/src/i18n/locales/en/composer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export default {
33
send: 'Send ↵',
44
queueLabel: 'Queue',
55
placeholderRunning: 'Press Enter to queue · Ctrl+S to inject into the running turn',
6+
starting: 'Sending…',
67
queueAutoDrain: 'sends automatically when the current turn ends',
78
queueNext: 'Up next',
89
queueDragTitle: 'Drag to reorder',

apps/kimi-web/src/i18n/locales/en/conversation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export default {
33
toc: 'Conversation outline',
44
newMessages: 'Latest messages',
55
loading: 'Loading…',
6+
starting: 'Starting conversation…',
67
emptyWorkspaceHint: 'Send in {name}',
78
switchWorkspace: 'Switch workspace',
89
addWorkspace: 'New workspace',

apps/kimi-web/src/i18n/locales/zh/composer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export default {
33
send: '发送 ↵',
44
queueLabel: '队列',
55
placeholderRunning: '输入会加入队列 · Ctrl+S 立即插入运行中的回合',
6+
starting: '正在发送…',
67
queueAutoDrain: '当前回合结束后自动逐条发送',
78
queueNext: '下一条',
89
queueDragTitle: '拖拽排序',

0 commit comments

Comments
 (0)