From 0d7484a1d8634e2c37fd6912e8b206fbfe7cb9ce Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 9 Jul 2026 21:42:12 +0800 Subject: [PATCH 1/9] fix(web): prevent duplicate first prompts and keep goal drives from looking idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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'. --- apps/kimi-web/src/App.vue | 1 + .../src/api/daemon/agentEventProjector.ts | 25 ++++++++++- .../kimi-web/src/components/chat/Composer.vue | 41 ++++++++++++++++--- .../src/components/chat/ConversationPane.vue | 26 +++++++++--- .../composables/client/useWorkspaceState.ts | 27 ++++++++++++ .../src/composables/useKimiWebClient.ts | 8 ++++ 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 + .../test/agent-event-projector.test.ts | 27 ++++++++++++ 11 files changed, 147 insertions(+), 12 deletions(-) diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index eb9131b48b..5ceced0eba 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -738,6 +738,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/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index a63d781cfc..79430c6f3a 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -125,6 +125,14 @@ interface SessionState { // Subagent lifecycle deltas after spawned only carry subagentId. Keep the // spawned metadata here so later updates can replace the full AppTask. subagentMeta: Map; + + // Goal lifecycle. While a goal is `active`, the core keeps `activeTurn` set + // across continuation turns (driveGoal), so a turn.ended in that window does + // NOT mean the session is idle. Track it here so turn.ended can keep the + // session 'running' instead of projecting a false 'idle' — which would let + // onSessionIdle drain the local queue into a still-busy core and trip + // `turn.agent_busy`. + goalActive: boolean; } function createSessionState(): SessionState { @@ -145,6 +153,7 @@ function createSessionState(): SessionState { model: '', messages: [], subagentMeta: new Map(), + goalActive: false, }; } @@ -973,8 +982,18 @@ export function createAgentProjector(): AgentProjector { const usageSnapshot = buildUsageSnapshot(s); out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot }); + // While a goal is still active, this turn.ended is just the boundary + // between two goal-driven continuation turns — the core kept + // `activeTurn` set, so the session is NOT idle. Projecting 'idle' here + // would fire onSessionIdle, drain the local queue, and hit the still- + // busy core with `turn.agent_busy`. Keep 'running' until the goal + // actually completes (goal.updated clears s.goalActive). const newStatus = - reason === 'cancelled' || reason === 'failed' || reason === 'filtered' ? 'aborted' : 'idle'; + reason === 'cancelled' || reason === 'failed' || reason === 'filtered' + ? 'aborted' + : s.goalActive + ? 'running' + : 'idle'; out.push({ type: 'sessionStatusChanged', sessionId, @@ -1209,6 +1228,10 @@ export function createAgentProjector(): AgentProjector { case 'goal.updated': { const goal = mapGoalSnapshot(p?.snapshot ?? null); + // Mirror goal-active into per-session projector state so a later + // turn.ended knows whether the core is merely between continuation + // turns (goal still driving) or truly idle. + s.goalActive = goal?.status === 'active'; out.push({ type: 'goalUpdated', sessionId, diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index e8069b0ec4..f3792b63a8 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -33,6 +33,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[]; @@ -56,6 +59,7 @@ const props = withDefaults(defineProps<{ hideContext?: boolean; }>(), { running: false, + starting: false, queued: () => [], searchFiles: undefined, uploadImage: undefined, @@ -65,11 +69,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<{ @@ -821,6 +827,7 @@ function selectModel(modelId: string): void { v-model="text" class="ph" :placeholder="placeholder" + :disabled="starting" rows="1" @keydown="handleKeydown" @compositionstart="handleCompositionStart" @@ -997,10 +1004,13 @@ function selectModel(modelId: string): void { @@ -1384,6 +1394,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; } diff --git a/apps/kimi-web/src/components/chat/ConversationPane.vue b/apps/kimi-web/src/components/chat/ConversationPane.vue index f3f06d05e2..8a65e6ea4f 100644 --- a/apps/kimi-web/src/components/chat/ConversationPane.vue +++ b/apps/kimi-web/src/components/chat/ConversationPane.vue @@ -11,6 +11,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'; @@ -45,6 +46,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; @@ -1050,10 +1054,14 @@ defineExpose({ loadComposerForEdit, focusComposer });
- {{ t('composer.emptyConversationTitle') }} - {{ t('composer.emptyConversation') }} - -
+ + + {{ starting ? t('conversation.starting') : t('composer.emptyConversationTitle') }} + + {{ t('composer.emptyConversation') }} + +