mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
046b6c4175
commit
ec8dc3456c
12 changed files with 121 additions and 12 deletions
5
.changeset/web-first-prompt-fixes.md
Normal file
5
.changeset/web-first-prompt-fixes.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export default {
|
|||
send: '发送 ↵',
|
||||
queueLabel: '队列',
|
||||
placeholderRunning: '输入会加入队列 · Ctrl+S 立即插入运行中的回合',
|
||||
starting: '正在发送…',
|
||||
queueAutoDrain: '当前回合结束后自动逐条发送',
|
||||
queueNext: '下一条',
|
||||
queueDragTitle: '拖拽排序',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export default {
|
|||
toc: '对话目录',
|
||||
newMessages: '最新消息',
|
||||
loading: '加载中…',
|
||||
starting: '正在创建对话…',
|
||||
emptyWorkspaceHint: '在 {name} 中发送',
|
||||
switchWorkspace: '切换工作区',
|
||||
addWorkspace: '添加工作区',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue