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/fix-web-btw-new-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix `/btw [<question>]` opening an empty side chat on the new-session screen.
5 changes: 5 additions & 0 deletions .changeset/fix-web-goal-new-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix `/goal <objective>` silently doing nothing on the new-session screen.
5 changes: 5 additions & 0 deletions .changeset/fix-web-skill-activation-new-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix slash skill activations (for example `/pre-changelog`) silently doing nothing on the new-session screen.
11 changes: 9 additions & 2 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -488,11 +488,18 @@ function handleCommand(cmd: string): void {
// Not a built-in command → treat it as a session skill activation
// (the user picked `/<skill>` from the menu, or typed `/<skill> args`).
// The daemon answers an unknown name with skill.not_found, surfaced as a
// warning, so a stray slash is harmless.
// warning, so a stray slash is harmless. With no active session, create
// one first (same path as the first prompt) so the activation isn't
// silently dropped on the new-session screen.
const space = cmd.indexOf(' ');
const name = (space === -1 ? cmd : cmd.slice(0, space)).slice(1);
const args = space === -1 ? undefined : cmd.slice(space + 1).trim() || undefined;
if (name) void client.activateSkill(name, args);
if (!name) break;
if (!client.activeSessionId.value && client.activeWorkspaceId.value) {
void client.startSessionAndActivateSkill(client.activeWorkspaceId.value, name, args);
Comment on lines +498 to +499

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 Avoid creating sessions for unknown slash commands

When the empty composer receives a non-built-in slash typo such as /pre-changlog, this branch creates a real backend session before activateSkill can return skill.not_found; the optimistic message is removed on failure, but the blank session remains active/in the sidebar. That regresses the documented harmless stray-slash behavior for the new-session screen, so validate the name against the loaded workspace skills (or otherwise avoid creating a session) before taking this creation path.

Useful? React with 👍 / 👎.

} else {
void client.activateSkill(name, args);
}
break;
}
}
Expand Down
12 changes: 8 additions & 4 deletions apps/kimi-web/src/composables/client/useModelProviderState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export interface UseModelProviderStateDeps {
opts?: { title?: string; message?: string; sessionId?: string },
) => void;
refreshSessionStatus: (sessionId: string) => Promise<void>;
persistSessionProfile: (patch: PersistSessionProfilePatch) => void;
persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise<void>;
activity: ComputedRef<ActivityState>;
inFlightPromptSessions: Set<string>;
saveThinkingToStorage: (v: ThinkingLevel) => void;
Expand Down Expand Up @@ -241,9 +241,13 @@ export function useModelProviderState(
* Activate a session skill (the web analogue of typing `/<skill> <args>` in the
* TUI). The daemon starts a turn with a `skill_activation` origin; progress
* arrives over the WS stream like any other turn. Never crashes the caller.
*
* `sessionId` overrides the active session — used when activating right after
* creating a session, so a concurrent session switch can't redirect the
* activation to the wrong session. No session at all is a no-op.
*/
async function activateSkill(skillName: string, args?: string): Promise<void> {
const sid = rawState.activeSessionId;
async function activateSkill(skillName: string, args?: string, sessionId?: string): Promise<void> {
const sid = sessionId ?? rawState.activeSessionId;
if (!sid) return;
const guarded = activity.value === 'idle' && !inFlightPromptSessions.has(sid);
const tempId = `msg_skill_opt_${Date.now().toString(36)}`;
Expand Down Expand Up @@ -387,7 +391,7 @@ export function useModelProviderState(
* session profile so the daemon's /status reflects it; still sent per-prompt). */
function setThinking(level: ThinkingLevel): void {
const next = applyThinkingLevel(level);
persistSessionProfile({ thinking: next });
void persistSessionProfile({ thinking: next });
}

return {
Expand Down
80 changes: 65 additions & 15 deletions apps/kimi-web/src/composables/client/useSideChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@

import { computed, ref } from 'vue';
import { getKimiWebApi } from '../../api';
import type { AppMessage } from '../../api/types';
import type { AppMessage, AppModel } from '../../api/types';
import type { KimiEventConnection } from '../../api/types';
import { messagesToTurns } from '../messagesToTurns';
import type { ChatTurn } from '../../types';
import { coerceThinkingForModel } from '../../lib/modelThinking';
import type { ExtendedState } from '../useKimiWebClient';

export interface UseSideChatDeps {
Expand All @@ -24,6 +25,10 @@ export interface UseSideChatDeps {
nextOptimisticMsgId: () => string;
connectEventsIfNeeded: () => void;
getEventConn: () => KimiEventConnection | null;
/** Provider model catalog — used to coerce thinking against the parent
* session's model the same way normal prompts do (so a value carried over
* from another model isn't submitted raw). */
models: () => AppModel[];
}

export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
Expand Down Expand Up @@ -146,7 +151,14 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
async function openSideChat(initialPrompt?: string): Promise<void> {
const parent = rawState.activeSessionId;
if (!parent) return;
// Reuse the existing side chat for this session if it already exists.
await openSideChatOn(parent, initialPrompt);
}

/** Low-level: open the side chat on an explicit parent session id.
* Used when the parent was just created from the empty composer so the call
* can target it directly instead of reading the active session (which could
* race with a concurrent session switch). */
async function openSideChatOn(parent: string, initialPrompt?: string): Promise<void> {
if (!sideChatTargetBySession.value[parent]) {
let agentId: string;
try {
Expand All @@ -167,24 +179,19 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
getEventConn()?.markSideChannelAgent(agentId);
}
if (initialPrompt && initialPrompt.trim()) {
await sendSideChatPrompt(initialPrompt.trim());
await sendSideChatPromptOn(parent, initialPrompt.trim());
}
}

function closeSideChat(): void {
const sid = rawState.activeSessionId;
if (!sid) return;
const { [sid]: _removed, ...rest } = sideChatTargetBySession.value;
void _removed;
sideChatTargetBySession.value = rest;
}

/** Send a plain prompt to the side-chat child (no plan/swarm/goal modes). */
async function sendSideChatPrompt(text: string): Promise<void> {
const target = activeSideChatTarget.value;
/** Low-level: send a prompt to the side-chat child of an explicit parent session.
* Always uses `parent` as the session id, carrying model / thinking /
* permissionMode / plan / swarm so the turn matches the UI regardless of
* parent /profile inheritance or race. */
async function sendSideChatPromptOn(parent: string, text: string): Promise<void> {
const target = sideChatTargetBySession.value[parent];
const trimmed = text.trim();
if (!target || !trimmed) return;
const sid = target.parentId;
const sid = parent;
const agentId = target.agentId;
rawState.sideChatSendingByAgent = { ...rawState.sideChatSendingByAgent, [agentId]: true };
const userMsg: AppMessage = {
Expand All @@ -197,9 +204,33 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
};
appendSideChatMessage(agentId, userMsg);
try {
// Carry the parent's current thinking level, model, and permission so a
// BTW first-turn reflects the same draft/runtime controls the UI shows —
// the parent session profile mirrors them, but the prompt itself is the
// only thing the daemon reads for this turn.
const promptSession = rawState.sessions.find((s) => s.id === sid);
const model =
(promptSession?.model && promptSession.model.length > 0
? promptSession.model
: rawState.defaultModel) ?? undefined;
// Coerce thinking against the parent model the same way a normal prompt
// does (coercePromptThinking in useWorkspaceState): a level carried over
// from another/default model would otherwise be submitted raw and run
// differently from what the UI shows.
const promptModel =
model === undefined
? undefined
: deps.models().find(
(m) => m.model === model || m.id === model || m.displayName === model,
);
const result = await getKimiWebApi().submitPrompt(sid, {
content: [{ type: 'text', text: trimmed }],
agentId,
model,
thinking: coerceThinkingForModel(promptModel, rawState.thinking),
permissionMode: rawState.permission,
planMode: rawState.planModeBySession[sid] ?? false,
swarmMode: rawState.swarmModeBySession[sid] ?? false,
});
stampLastSideChatUserPrompt(agentId, result.promptId);
rawState.sideChatUserMessageIdsBySession = {
Expand All @@ -213,6 +244,24 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
}
}

function closeSideChat(): void {
const sid = rawState.activeSessionId;
if (!sid) return;
const { [sid]: _removed, ...rest } = sideChatTargetBySession.value;
void _removed;
sideChatTargetBySession.value = rest;
}

/** Send a plain prompt to the active session's side chat, carrying the
* controls (model, thinking, permissionMode, plan/swarm) the UI shows so a
* BTW first turn matches them even if the parent's /profile is still in
* flight. */
async function sendSideChatPrompt(text: string): Promise<void> {
const target = activeSideChatTarget.value;
if (!target) return;
await sendSideChatPromptOn(target.parentId, text);
}

// When a session is deleted, drop its side-chat target so it cannot leak into a
// later session that happens to reuse the same id.
function clearSideChatForSession(sessionId: string): void {
Expand All @@ -232,6 +281,7 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
appendSideChatAssistantText,
finishSideChatAgent,
openSideChat,
openSideChatOn,
closeSideChat,
sendSideChatPrompt,
clearSideChatForSession,
Expand Down
Loading
Loading