Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-first-prompt-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-web/src/components/chat/ChatDock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -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)"
Expand Down
41 changes: 35 additions & 6 deletions apps/kimi-web/src/components/chat/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -59,6 +62,7 @@ const props = withDefaults(defineProps<{
hideContext?: boolean;
}>(), {
running: false,
starting: false,
queued: () => [],
searchFiles: undefined,
uploadImage: undefined,
Expand All @@ -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<{
Expand Down Expand Up @@ -913,6 +919,7 @@ function selectModel(modelId: string): void {
v-model="text"
class="ph"
:placeholder="placeholder"
:disabled="starting"
rows="1"
@keydown="handleKeydown"
@compositionstart="handleCompositionStart"
Expand Down Expand Up @@ -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>

Expand Down Expand Up @@ -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);
Expand Down
27 changes: 22 additions & 5 deletions apps/kimi-web/src/components/chat/ConversationPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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" />
Expand Down Expand Up @@ -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')"
Expand All @@ -1225,6 +1233,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
:models="models"
:starred-ids="starredIds"
:skills="skills"
:starting="starting"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass the starting guard through the dock composer

This only disables the centered empty Composer. During startSessionAndSendPrompt, createDraftSession calls selectSession, which sets activeSessionId while it is still awaiting the snapshot, before the original prompt is submitted; at that point this pane switches to ChatDock, whose Composer is not given starting. On a slow snapshot, a user can type/submit from the dock, sendPrompt sees no inFlightPromptSessions yet, and then the original submitPromptInternal runs too, recreating the concurrent first-turn POST this change is meant to prevent.

Useful? React with 👍 / 👎.

hide-context
@submit="handleComposerSubmit"
@steer="emit('steer', $event)"
Expand Down Expand Up @@ -1286,6 +1295,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
:style="chatDockStyle"
:session-id="sessionId"
:running="running"
:starting="starting"
:queued="queued"
:search-files="searchFiles"
:upload-image="uploadImage"
Expand Down Expand Up @@ -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);
Expand Down
42 changes: 42 additions & 0 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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;
Expand All @@ -862,6 +888,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
: rawState.defaultModel) ?? undefined;
await persistSessionProfile(
{
model,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fix the failing profile-patch test expectation

When running the existing useWorkspaceState — startSessionAndActivateSkill test, the mocked upsertSessionFront does not add sess_new and defaultModel is null, so this newly added field is passed as { model: undefined, ... } to persistSessionProfile; the assertion at apps/kimi-web/test/workspace-state.test.ts:640 still expects an object without model, which will fail the web test suite. Update the test expectation to include the resolved/undefined model field or adjust the setup to reflect the new behavior.

Useful? React with 👍 / 👎.

planMode,
swarmMode,
permissionMode: rawState.permission,
Expand All @@ -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);
}
}

Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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,
};
}

Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2520,6 +2525,7 @@ export function useKimiWebClient() {
questions,
activity,
isSending,
isStartingFirstPrompt,
fastMoon: appearance.fastMoon,

// Model + Provider reactive state
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/i18n/locales/en/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/i18n/locales/en/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/i18n/locales/zh/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export default {
send: '发送 ↵',
queueLabel: '队列',
placeholderRunning: '输入会加入队列 · Ctrl+S 立即插入运行中的回合',
starting: '正在发送…',
queueAutoDrain: '当前回合结束后自动逐条发送',
queueNext: '下一条',
queueDragTitle: '拖拽排序',
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/i18n/locales/zh/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export default {
toc: '对话目录',
newMessages: '最新消息',
loading: '加载中…',
starting: '正在创建对话…',
emptyWorkspaceHint: '在 {name} 中发送',
switchWorkspace: '切换工作区',
addWorkspace: '添加工作区',
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-web/test/workspace-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading