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-plan-review-card.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Show the plan body and approach choices in the plan review card when exiting plan mode in the web UI.
21 changes: 21 additions & 0 deletions apps/kimi-web/src/api/daemon/eventReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export interface KimiClientState {
activeSessionId?: string;
messagesBySession: Record<string, AppMessage[]>;
approvalsBySession: Record<string, AppApprovalRequest[]>;
/** Preserved `plan_review` displays keyed by toolCallId. Plan content survives
* approval resolution so the ExitPlanMode tool card can keep rendering the
* plan (approved / rejected / revised) instead of losing it. */
planReviewByToolCallId: Record<string, { plan: string; path?: string }>;
questionsBySession: Record<string, AppQuestionRequest[]>;
tasksBySession: Record<string, AppTask[]>;
goalBySession: Record<string, AppGoal>;
Expand All @@ -58,6 +62,7 @@ export function createInitialState(): KimiClientState {
activeSessionId: undefined,
messagesBySession: {},
approvalsBySession: {},
planReviewByToolCallId: {},
questionsBySession: {},
tasksBySession: {},
goalBySession: {},
Expand All @@ -77,6 +82,7 @@ function cloneState(s: KimiClientState): KimiClientState {
sessions: [...s.sessions],
messagesBySession: { ...s.messagesBySession },
approvalsBySession: { ...s.approvalsBySession },
planReviewByToolCallId: { ...s.planReviewByToolCallId },
questionsBySession: { ...s.questionsBySession },
tasksBySession: { ...s.tasksBySession },
goalBySession: { ...s.goalBySession },
Expand Down Expand Up @@ -454,6 +460,21 @@ export function reduceAppEvent(
if (!exists) {
next.approvalsBySession[sid] = [...list, event.approval];
}
// Preserve a plan_review display so the plan stays visible in the
// ExitPlanMode tool card after the approval resolves.
const display = event.approval.display as
| { kind?: unknown; plan?: unknown; path?: unknown }
| null
| undefined;
if (display?.kind === 'plan_review' && typeof display.plan === 'string' && display.plan.length > 0) {
next.planReviewByToolCallId = {
...next.planReviewByToolCallId,
[event.approval.toolCallId]: {
plan: display.plan,
path: typeof display.path === 'string' ? display.path : undefined,
},
};
}
break;
}

Expand Down
110 changes: 98 additions & 12 deletions apps/kimi-web/src/components/chat/ApprovalCard.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
<!-- apps/kimi-web/src/components/chat/ApprovalCard.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ApprovalBlock } from '../../types';
import type { ApprovalDecision } from '../../api/types';
import Markdown from './Markdown.vue';

const props = defineProps<{
block: ApprovalBlock;
agentName?: string;
}>();

const emit = defineEmits<{
decide: [response: { decision: ApprovalDecision; scope?: 'session'; feedback?: string }];
decide: [response: { decision: ApprovalDecision; scope?: 'session'; feedback?: string; selectedLabel?: string }];
}>();

const { t } = useI18n();

interface PlanReviewView {
plan: string;
path?: string;
options: { label: string; description?: string }[];
}

const planReview = computed<PlanReviewView | null>(() => {
const b = props.block;
if (b.kind !== 'plan_review') return null;
return { plan: b.plan, path: b.path, options: b.options ?? [] };
});

// Temporarily collapse to a thin bar so the approval stops covering the chat
// while the user reads. The decision buttons + body return on expand.
const minimized = ref(false);
Expand All @@ -24,7 +37,7 @@ const minimized = ref(false);
// Title by kind
// ---------------------------------------------------------------------------

const titleKinds = ['shell', 'diff', 'file', 'fileop', 'url', 'search', 'invocation', 'todo', 'generic'];
const titleKinds = ['shell', 'diff', 'file', 'fileop', 'url', 'search', 'invocation', 'todo', 'plan_review', 'generic'];

function title(): string {
const kind = titleKinds.includes(props.block.kind) ? props.block.kind : 'generic';
Expand All @@ -48,7 +61,12 @@ function openFeedback(): void {

function submitFeedback(): void {
const fb = feedbackText.value.trim();
emit('decide', { decision: 'rejected', feedback: fb || undefined });
if (planReview.value) {
// Revise: keep plan mode active and pass optional feedback to the agent.
emit('decide', { decision: 'rejected', selectedLabel: 'Revise', feedback: fb || undefined });
} else {
emit('decide', { decision: 'rejected', feedback: fb || undefined });
}
feedbackOpen.value = false;
feedbackText.value = '';
}
Expand Down Expand Up @@ -76,8 +94,16 @@ function approve(): void { emit('decide', { decision: 'approved' }); }
function approveSession(): void { emit('decide', { decision: 'approved', scope: 'session' }); }
function reject(): void { emit('decide', { decision: 'rejected' }); }

// plan_review actions
function approvePlan(): void { emit('decide', { decision: 'approved' }); }
function approveOption(label: string): void { emit('decide', { decision: 'approved', selectedLabel: label }); }
function revisePlan(): void { openFeedback(); }
function rejectAndExitPlan(): void { emit('decide', { decision: 'rejected', selectedLabel: 'Reject and Exit' }); }

// ---------------------------------------------------------------------------
// Number key shortcuts: 1=approve, 2=session, 3=reject, 4=feedback
// Number key shortcuts. Generic cards: 1=approve, 2=session, 3=reject,
// 4=feedback. Plan review cards: 1/2/3 map to the offered approaches (or
// approve / revise / reject-and-exit when no approaches are offered).
// Guard: do not fire when a textarea/input is focused
// ---------------------------------------------------------------------------

Expand All @@ -86,6 +112,19 @@ function handleKeydown(e: KeyboardEvent): void {
if (tag === 'input' || tag === 'textarea') return;
// Hidden actions shouldn't fire from number keys while minimized.
if (minimized.value) return;
const pr = planReview.value;
if (pr) {
if (pr.options.length === 0) {
if (e.key === '1') { e.preventDefault(); approvePlan(); }
else if (e.key === '2') { e.preventDefault(); revisePlan(); }
else if (e.key === '3') { e.preventDefault(); rejectAndExitPlan(); }
return;
}
if (e.key === '1' && pr.options[0]) { e.preventDefault(); approveOption(pr.options[0].label); }
else if (e.key === '2' && pr.options[1]) { e.preventDefault(); approveOption(pr.options[1].label); }
else if (e.key === '3' && pr.options[2]) { e.preventDefault(); approveOption(pr.options[2].label); }
return;
}
if (e.key === '1') { e.preventDefault(); approve(); }
else if (e.key === '2') { e.preventDefault(); approveSession(); }
else if (e.key === '3') { e.preventDefault(); reject(); }
Expand Down Expand Up @@ -124,6 +163,8 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));

<!-- Body + actions collapse when minimized -->
<template v-if="!minimized">
<!-- plan_review: plan file path on the header's second line -->
<div v-if="block.kind === 'plan_review' && block.path" class="ah-path" :title="block.path">{{ block.path }}</div>
<!-- Body by kind -->

<!-- diff -->
Expand Down Expand Up @@ -187,6 +228,11 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
</div>
</div>

<!-- plan_review -->
<div v-else-if="block.kind === 'plan_review'" class="body-plan">
<Markdown :text="block.plan" />
</div>

<!-- generic -->
<div v-else class="body-generic">
<span class="gen-text">{{ block.summary }}</span>
Expand All @@ -205,8 +251,24 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
<div class="feedback-hint">{{ t('approval.feedbackHint') }}</div>
</div>

<!-- Actions row -->
<div class="abtn">
<!-- plan_review actions -->
<div v-if="planReview" class="plan-actions">
<template v-if="planReview.options.length > 0">
<div
v-for="(opt, i) in planReview.options"
:key="i"
class="kbtn pri"
:title="opt.description"
@click="approveOption(opt.label)"
>{{ opt.label }}<span class="k">[{{ i + 1 }}]</span></div>
</template>
<div v-else class="kbtn pri" @click="approvePlan">{{ t('approval.approvePlan') }}<span class="k">[1]</span></div>
<div class="kbtn" @click="revisePlan">{{ t('approval.revise') }}<span v-if="planReview.options.length === 0" class="k">[2]</span></div>
<div class="kbtn danger" @click="rejectAndExitPlan">{{ t('approval.rejectAndExit') }}<span v-if="planReview.options.length === 0" class="k">[3]</span></div>
</div>

<!-- default actions row -->
<div v-else class="abtn">
<div class="kbtn pri" @click="approve">{{ t('approval.approve') }}<span class="k">[1]</span></div>
<div class="kbtn" @click="approveSession">{{ t('approval.approveSession') }}<span class="k">[2]</span></div>
<div class="kbtn" @click="reject">{{ t('approval.reject') }}<span class="k">[3]</span></div>
Expand All @@ -224,7 +286,8 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
border-radius: 3px;
}

/* Header */
/* Header — single row: title + truncating path on the left, APPROVAL REQUIRED
badge + minimize button pinned to the right (never wrap onto a second line). */
.ah {
padding: 7px 10px;
background: var(--soft);
Expand All @@ -234,10 +297,22 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
font-size: var(--ui-font-size);
border-bottom: 1px solid var(--bd);
border-radius: 3px 3px 0 0;
flex-wrap: wrap;
flex-wrap: nowrap;
}
.akind { color: var(--blue2); font-weight: 700; white-space: nowrap; flex: none; }
.apath { color: var(--text); font-family: var(--mono); font-size: calc(var(--ui-font-size) - 2.5px); flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Header second line — full-width plan file path, below the title row. */
.ah-path {
padding: 4px 10px 6px;
background: var(--soft);
border-bottom: 1px solid var(--bd);
color: var(--muted);
font-family: var(--mono);
font-size: calc(var(--ui-font-size) - 3px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.akind { color: var(--blue2); font-weight: 700; white-space: nowrap; }
.apath { color: var(--text); font-family: var(--mono); font-size: calc(var(--ui-font-size) - 2.5px); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.abadge {
font-size: max(9px, calc(var(--ui-font-size) - 4px));
color: var(--muted);
Expand All @@ -248,6 +323,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
}
.aw {
margin-left: auto;
flex: none;
color: var(--blue2);
border: 1px solid var(--bd);
padding: 1px 7px;
Expand Down Expand Up @@ -364,6 +440,10 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
/* Generic */
.body-generic { padding: 10px 12px; font-size: calc(var(--ui-font-size) - 1.5px); color: var(--text); word-break: break-word; }

/* Plan review — Markdown body, capped at half the viewport height with scroll
for longer plans. */
.body-plan { padding: 4px 12px 10px; max-height: 50vh; overflow-y: auto; }

/* Feedback */
.feedback-wrap {
padding: 8px 12px;
Expand Down Expand Up @@ -410,6 +490,11 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
.k { color: var(--faint); margin-left: 6px; font-size: max(9px, calc(var(--ui-font-size) - 4px)); }
.kbtn.pri .k { color: color-mix(in srgb, var(--bg) 60%, transparent); }

/* Plan review actions — wraps on desktop so several approach buttons fit. */
.plan-actions { display: flex; flex-wrap: wrap; border-top: 1px solid var(--line); }
.plan-actions .kbtn.danger { color: var(--err); }
.plan-actions .kbtn.danger:hover { background: color-mix(in srgb, var(--err) 8%, var(--bg)); }

/* =========================================================================
MOBILE (≤640px): the card spans the full chat column (no 33px left gutter),
inner previews scroll horizontally instead of overflowing the page, and the
Expand Down Expand Up @@ -439,7 +524,8 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
/* Actions → full-width stacked rows, each a tall ≥44px tap target. The
primary Approve sits on top; the rest stack below, separated by hairlines.
Stacking (vs. a cramped 4-up row) keeps every label legible at 360px. */
.abtn { flex-direction: column; }
.abtn,
.plan-actions { flex-direction: column; }
.kbtn {
min-height: 46px;
display: flex;
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-web/src/components/chat/ChatDock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const emit = defineEmits<{
selectModel: [modelId: string];
answer: [questionId: string, response: QuestionResponse];
dismiss: [questionId: string];
approval: [approvalId: string, response: { decision: 'approved' | 'rejected' | 'cancelled'; scope?: 'session'; feedback?: string }];
approval: [approvalId: string, response: { decision: 'approved' | 'rejected' | 'cancelled'; scope?: 'session'; feedback?: string; selectedLabel?: string }];
cancelTask: [taskId: string];
'toggle-dock-panel': [panel: 'bash' | 'subagent' | 'todos' | 'queue'];
'close-dock-panel': [];
Expand Down
8 changes: 4 additions & 4 deletions apps/kimi-web/src/components/chat/ChatPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -522,11 +522,11 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
<ThinkingBlock v-if="blk.kind === 'thinking'" :text="blk.thinking" :mobile="childBubble" :streaming="isStreamingRenderBlock(turn, blk)" @open="emit('openThinking', { turnId: turn.id, blockIndex: blk.sourceIndex })" />
<div v-else-if="blk.kind === 'text' && blk.text" class="msg"><Markdown :text="blk.text" :streaming="isStreamingRenderBlock(turn, blk)" :open-file="(target) => emit('openFile', target)" /></div>
<div v-else-if="blk.kind === 'tool-stack'" class="tool-stack">
<ToolCall v-for="(item, si) in blk.tools" :key="toolStackKey(item)" :tool="item.tool" :mobile="childBubble" :stack-position="toolStackPosition(si, blk.tools.length)" @open-media="emit('openMedia', $event)" />
<ToolCall v-for="(item, si) in blk.tools" :key="toolStackKey(item)" :tool="item.tool" :mobile="childBubble" :stack-position="toolStackPosition(si, blk.tools.length)" @open-media="emit('openMedia', $event)" @open-file="emit('openFile', $event)" />
</div>
<AgentCard v-else-if="blk.kind === 'agent'" :member="blk.member" @open="emit('openAgent', { turnId: turn.id, blockIndex: blk.sourceIndex, memberId: $event })" />
<AgentGroup v-else-if="blk.kind === 'agentGroup'" :members="blk.members" @open="emit('openAgent', { turnId: turn.id, blockIndex: blk.sourceIndex, memberId: $event })" />
<ToolCall v-else-if="blk.kind === 'tool'" :tool="blk.tool" :mobile="childBubble" @open-media="emit('openMedia', $event)" />
<ToolCall v-else-if="blk.kind === 'tool'" :tool="blk.tool" :mobile="childBubble" @open-media="emit('openMedia', $event)" @open-file="emit('openFile', $event)" />
</template>
<div v-if="turn.id !== streamingTurnId && isAssistantRunEnd(ti) && (assistantRunFinalText(ti).trim().length > 0 || turn.durationMs !== undefined)" class="a-msg-ft">
<span v-if="turn.durationMs !== undefined" class="a-duration" :title="`${turn.durationMs} ms`">{{ formatDuration(turn.durationMs) }}</span>
Expand Down Expand Up @@ -664,11 +664,11 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
<ThinkingBlock v-if="blk.kind === 'thinking'" :text="blk.thinking" :streaming="isStreamingRenderBlock(turn, blk)" @open="emit('openThinking', { turnId: turn.id, blockIndex: blk.sourceIndex })" />
<Markdown v-else-if="blk.kind === 'text' && blk.text" :text="blk.text" :streaming="isStreamingRenderBlock(turn, blk)" :open-file="(target) => emit('openFile', target)" />
<div v-else-if="blk.kind === 'tool-stack'" class="tool-stack">
<ToolCall v-for="(item, si) in blk.tools" :key="toolStackKey(item)" :tool="item.tool" :stack-position="toolStackPosition(si, blk.tools.length)" @open-media="emit('openMedia', $event)" />
<ToolCall v-for="(item, si) in blk.tools" :key="toolStackKey(item)" :tool="item.tool" :stack-position="toolStackPosition(si, blk.tools.length)" @open-media="emit('openMedia', $event)" @open-file="emit('openFile', $event)" />
</div>
<AgentCard v-else-if="blk.kind === 'agent'" :member="blk.member" @open="emit('openAgent', { turnId: turn.id, blockIndex: blk.sourceIndex, memberId: $event })" />
<AgentGroup v-else-if="blk.kind === 'agentGroup'" :members="blk.members" @open="emit('openAgent', { turnId: turn.id, blockIndex: blk.sourceIndex, memberId: $event })" />
<ToolCall v-else-if="blk.kind === 'tool'" :tool="blk.tool" @open-media="emit('openMedia', $event)" />
<ToolCall v-else-if="blk.kind === 'tool'" :tool="blk.tool" @open-media="emit('openMedia', $event)" @open-file="emit('openFile', $event)" />
</template>
</template>
</div>
Expand Down
Loading
Loading