mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
feat(web): render plan review card with plan body and approach choices (#1101)
* feat(web): render plan review card with plan body and approach choices The ExitPlanMode plan_review approval in the web UI now renders the plan body as Markdown with one button per approach option, plus Revise and Reject-and-Exit, with the selected label threaded back to the server. The approval header keeps APPROVAL REQUIRED and the minimize control on the title row and shows the plan path on a second line, and the plan body uses up to half the viewport height. The ExitPlanMode tool card also gains a link to the plan file, currently hidden behind a flag until the server can read files outside the workspace. * fix(web): hide misleading shortcut numbers on plan review actions When a plan review has approach options, the option buttons already own [1]/[2]/[3]. Revise and Reject-and-Exit advertised the same numbers even though those keys approve an option, so hide their shortcut labels whenever options are present.
This commit is contained in:
parent
77412b89fa
commit
3ea6ac278d
12 changed files with 265 additions and 19 deletions
5
.changeset/web-plan-review-card.md
Normal file
5
.changeset/web-plan-review-card.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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>;
|
||||
|
|
@ -58,6 +62,7 @@ export function createInitialState(): KimiClientState {
|
|||
activeSessionId: undefined,
|
||||
messagesBySession: {},
|
||||
approvalsBySession: {},
|
||||
planReviewByToolCallId: {},
|
||||
questionsBySession: {},
|
||||
tasksBySession: {},
|
||||
goalBySession: {},
|
||||
|
|
@ -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 },
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<!-- 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;
|
||||
|
|
@ -11,11 +12,23 @@ const props = defineProps<{
|
|||
}>();
|
||||
|
||||
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);
|
||||
|
|
@ -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';
|
||||
|
|
@ -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 = '';
|
||||
}
|
||||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
|
@ -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(); }
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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': [];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!-- apps/kimi-web/src/components/chat/ToolCall.vue -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { ToolCall, ToolMedia } from '../../types';
|
||||
import type { FilePreviewRequest, ToolCall, ToolMedia } from '../../types';
|
||||
import { toolLabel, toolGlyph, toolChip, toolSummary } from '../../lib/toolMeta';
|
||||
|
||||
const props = withDefaults(
|
||||
|
|
@ -16,6 +16,7 @@ const props = withDefaults(
|
|||
);
|
||||
const emit = defineEmits<{
|
||||
openMedia: [media: ToolMedia];
|
||||
openFile: [target: FilePreviewRequest];
|
||||
}>();
|
||||
const isRunningBash = computed(() => props.tool.status === 'running' && /^bash$/i.test(props.tool.name));
|
||||
const hasOutput = computed(() => !!props.tool.output && props.tool.output.length > 0);
|
||||
|
|
@ -53,6 +54,25 @@ const chip = () => toolChip({
|
|||
const isError = () => props.tool.status === 'error';
|
||||
const media = computed(() => (props.tool.status === 'ok' ? props.tool.media : undefined));
|
||||
|
||||
// ExitPlanMode: expose the plan file as a clickable link (opens file preview).
|
||||
const isExitPlan = computed(() => props.tool.name === 'ExitPlanMode');
|
||||
const planPath = computed(() => (isExitPlan.value ? props.tool.planPath : undefined));
|
||||
const planBasename = computed(() => {
|
||||
const p = planPath.value;
|
||||
return p ? p.split(/[\\/]+/).pop() || p : '';
|
||||
});
|
||||
function openPlanFile(): void {
|
||||
if (planPath.value) emit('openFile', { path: planPath.value });
|
||||
}
|
||||
|
||||
// TEMP: plan-file preview link is hidden until the server can read files
|
||||
// outside the workspace. Plan files live under the session dir (not the cwd),
|
||||
// and the server's readFile is workspace-scoped, so the preview rejects them
|
||||
// with "outside workspace". The planPath wiring is kept in place; flip this
|
||||
// flag to re-enable the chip once a backend API can read the plan file.
|
||||
const enablePlanFileLink = false;
|
||||
const showPlanFileLink = computed(() => enablePlanFileLink && Boolean(planPath.value));
|
||||
|
||||
function basename(path: string): string {
|
||||
return path.split(/[\\/]+/).pop() || path;
|
||||
}
|
||||
|
|
@ -136,6 +156,13 @@ function openMediaPreview(): void {
|
|||
into the card body (below) so the header stays clean. -->
|
||||
<span v-if="!open" class="p" :title="summary()">{{ summary() }}</span>
|
||||
<span class="rt">
|
||||
<button
|
||||
v-if="showPlanFileLink"
|
||||
class="plan-link"
|
||||
type="button"
|
||||
:title="planPath"
|
||||
@click.stop="openPlanFile"
|
||||
>📄 {{ planBasename }}</button>
|
||||
<span class="chip" v-if="chip()">{{ chip() }}</span>
|
||||
<span
|
||||
v-if="tool.status === 'running'"
|
||||
|
|
@ -278,6 +305,22 @@ function openMediaPreview(): void {
|
|||
color: var(--dim);
|
||||
font-size: max(9px, calc(var(--ui-font-size) - 3.5px));
|
||||
}
|
||||
.plan-link {
|
||||
appearance: none;
|
||||
background: var(--panel2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
padding: 0 6px;
|
||||
color: var(--blue2);
|
||||
font: inherit;
|
||||
font-size: max(9px, calc(var(--ui-font-size) - 3.5px));
|
||||
cursor: pointer;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.plan-link:hover { background: var(--panel); color: var(--blue); border-color: var(--blue2); }
|
||||
.ok { color: var(--ok); font-weight: 700; }
|
||||
.er { color: var(--err); font-weight: 700; }
|
||||
.tm { color: var(--muted); }
|
||||
|
|
|
|||
|
|
@ -1205,7 +1205,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
|||
|
||||
async function respondApproval(
|
||||
approvalId: string,
|
||||
response: { decision: ApprovalDecision; scope?: 'session'; feedback?: string },
|
||||
response: { decision: ApprovalDecision; scope?: 'session'; feedback?: string; selectedLabel?: string },
|
||||
): Promise<void> {
|
||||
const sid = rawState.activeSessionId;
|
||||
if (!sid) return;
|
||||
|
|
@ -1215,6 +1215,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
|||
decision: response.decision,
|
||||
scope: response.scope,
|
||||
feedback: response.feedback,
|
||||
selectedLabel: response.selectedLabel,
|
||||
};
|
||||
await api.respondApproval(sid, approvalId, fullResponse);
|
||||
// Remove from local approvals immediately (WS event will confirm)
|
||||
|
|
|
|||
|
|
@ -322,6 +322,22 @@ function buildApprovalBlock(a: AppApprovalRequest): ApprovalBlock {
|
|||
return { kind: 'todo', items };
|
||||
}
|
||||
|
||||
if (kind === 'plan_review') {
|
||||
const plan = typeof d['plan'] === 'string' ? d['plan'] : '';
|
||||
const path = typeof d['path'] === 'string' ? d['path'] : undefined;
|
||||
const rawOptions = Array.isArray(d['options']) ? d['options'] : [];
|
||||
const options = rawOptions
|
||||
.map((item: unknown): { label: string; description?: string } | null => {
|
||||
const it = (item ?? {}) as Record<string, unknown>;
|
||||
const label = typeof it['label'] === 'string' ? it['label'] : '';
|
||||
if (!label) return null;
|
||||
const description = typeof it['description'] === 'string' ? it['description'] : undefined;
|
||||
return { label, description };
|
||||
})
|
||||
.filter((o): o is { label: string; description?: string } => o !== null);
|
||||
return { kind: 'plan_review', plan, path, options: options.length > 0 ? options : undefined };
|
||||
}
|
||||
|
||||
return { kind: 'generic', summary: a.action };
|
||||
}
|
||||
|
||||
|
|
@ -393,6 +409,19 @@ function continuesAssistantGroup(group: Group | null, promptId: string | undefin
|
|||
);
|
||||
}
|
||||
|
||||
/** Extract the plan file path from an ExitPlanMode tool result. The approved
|
||||
* output contains `Plan saved to: <path>`; this survives a page reload (unlike
|
||||
* the ephemeral plan_review approval display), so the tool card can still link
|
||||
* to the plan file. */
|
||||
function parsePlanSavedPath(output: string[] | undefined): string | undefined {
|
||||
if (!output || output.length === 0) return undefined;
|
||||
const marker = 'Plan saved to: ';
|
||||
for (const line of output) {
|
||||
if (line.startsWith(marker)) return line.slice(marker.length).trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function messagesToTurns(
|
||||
messages: AppMessage[],
|
||||
approvals: AppApprovalRequest[],
|
||||
|
|
@ -406,6 +435,9 @@ export function messagesToTurns(
|
|||
*/
|
||||
sessionActive = true,
|
||||
subagentTasks: AppTask[] = [],
|
||||
/** Preserved `plan_review` displays keyed by toolCallId — used to link the
|
||||
* ExitPlanMode tool card back to the plan file after the approval resolves. */
|
||||
planReviewByToolCallId: Record<string, { plan: string; path?: string }> = {},
|
||||
): ChatTurn[] {
|
||||
const turns: ChatTurn[] = [];
|
||||
let no = 1;
|
||||
|
|
@ -541,6 +573,7 @@ export function messagesToTurns(
|
|||
// flushGroup settles dangling tools of finished turns back to 'ok'.
|
||||
status: 'running',
|
||||
output: c.outputLines,
|
||||
planPath: c.toolName === 'ExitPlanMode' ? planReviewByToolCallId[c.toolCallId]?.path : undefined,
|
||||
};
|
||||
g.tools.push(toolCall);
|
||||
g.blocks.push({ kind: 'tool', tool: toolCall });
|
||||
|
|
@ -560,6 +593,12 @@ export function messagesToTurns(
|
|||
output: normalizeToolOutput(c.output),
|
||||
media: c.isError ? undefined : normalizeToolMedia(tool.name, c.output),
|
||||
};
|
||||
// ExitPlanMode: if the plan path wasn't captured from the (ephemeral)
|
||||
// approval display, recover it from the result output so the file link
|
||||
// survives a reload for approved plans.
|
||||
if (updated.name === 'ExitPlanMode' && !updated.planPath) {
|
||||
updated.planPath = parsePlanSavedPath(updated.output);
|
||||
}
|
||||
g.tools[idx] = updated;
|
||||
const blk = g.blocks.find((b) => b.kind === 'tool' && b.tool.id === c.toolCallId);
|
||||
if (blk && blk.kind === 'tool') blk.tool = updated;
|
||||
|
|
|
|||
|
|
@ -646,6 +646,7 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq
|
|||
activeSessionId: rawState.activeSessionId,
|
||||
messagesBySession: rawState.messagesBySession,
|
||||
approvalsBySession: rawState.approvalsBySession,
|
||||
planReviewByToolCallId: rawState.planReviewByToolCallId,
|
||||
questionsBySession: rawState.questionsBySession,
|
||||
tasksBySession: rawState.tasksBySession,
|
||||
goalBySession: rawState.goalBySession,
|
||||
|
|
@ -660,6 +661,7 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq
|
|||
setActiveSessionId(next.activeSessionId);
|
||||
setMessagesBySession(next.messagesBySession);
|
||||
rawState.approvalsBySession = next.approvalsBySession;
|
||||
rawState.planReviewByToolCallId = next.planReviewByToolCallId;
|
||||
rawState.questionsBySession = next.questionsBySession;
|
||||
rawState.tasksBySession = next.tasksBySession;
|
||||
rawState.goalBySession = next.goalBySession;
|
||||
|
|
@ -1045,6 +1047,20 @@ async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionRe
|
|||
...rawState.approvalsBySession,
|
||||
[sessionId]: snap.pendingApprovals,
|
||||
};
|
||||
// Preserve plan_review paths from the snapshot so the ExitPlanMode tool
|
||||
// card can link to the plan file even after a reload.
|
||||
for (const a of snap.pendingApprovals) {
|
||||
const display = a.display as { kind?: unknown; plan?: unknown; path?: unknown } | null | undefined;
|
||||
if (display?.kind === 'plan_review' && typeof display.plan === 'string' && display.plan.length > 0) {
|
||||
rawState.planReviewByToolCallId = {
|
||||
...rawState.planReviewByToolCallId,
|
||||
[a.toolCallId]: {
|
||||
plan: display.plan,
|
||||
path: typeof display.path === 'string' ? display.path : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
rawState.questionsBySession = {
|
||||
...rawState.questionsBySession,
|
||||
[sessionId]: snap.pendingQuestions,
|
||||
|
|
@ -1262,6 +1278,23 @@ function buildApprovalBlock(a: AppApprovalRequest): ApprovalBlock {
|
|||
return { kind: 'todo', items };
|
||||
}
|
||||
|
||||
// plan_review — finalised plan presented at plan-mode exit
|
||||
if (kind === 'plan_review') {
|
||||
const plan = typeof d.plan === 'string' ? d.plan : '';
|
||||
const path = typeof d.path === 'string' ? d.path : undefined;
|
||||
const rawOptions = Array.isArray(d.options) ? d.options : [];
|
||||
const options = rawOptions
|
||||
.map((item: unknown): { label: string; description?: string } | null => {
|
||||
const it = (item ?? {}) as Record<string, unknown>;
|
||||
const label = typeof it.label === 'string' ? it.label : '';
|
||||
if (!label) return null;
|
||||
const description = typeof it.description === 'string' ? it.description : undefined;
|
||||
return { label, description };
|
||||
})
|
||||
.filter((o): o is { label: string; description?: string } => o !== null);
|
||||
return { kind: 'plan_review', plan, path, options: options.length > 0 ? options : undefined };
|
||||
}
|
||||
|
||||
// Unknown daemon display.kind → 'generic' with summary = action
|
||||
return { kind: 'generic', summary: a.action };
|
||||
}
|
||||
|
|
@ -1454,6 +1487,7 @@ const turns = computed<ChatTurn[]>(() => {
|
|||
(fileId) => getKimiWebApi().getFileUrl(fileId),
|
||||
activity.value !== 'idle',
|
||||
activeAppTasks.value,
|
||||
rawState.planReviewByToolCallId,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export default {
|
|||
search: 'Search?',
|
||||
invocation: 'Invoke?',
|
||||
todo: 'Update todo?',
|
||||
plan_review: 'Ready to build with this plan?',
|
||||
generic: 'Approve action?',
|
||||
},
|
||||
subagentBadge: 'sub agent · {name}',
|
||||
|
|
@ -21,4 +22,7 @@ export default {
|
|||
approveSession: 'Approve for session',
|
||||
reject: 'Reject',
|
||||
feedback: '+Feedback',
|
||||
approvePlan: 'Approve plan',
|
||||
revise: 'Revise',
|
||||
rejectAndExit: 'Reject and Exit',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export default {
|
|||
search: '搜索?',
|
||||
invocation: '调用?',
|
||||
todo: '更新 todo?',
|
||||
plan_review: '按这份 plan 开始实现?',
|
||||
generic: '批准操作?',
|
||||
},
|
||||
subagentBadge: '子 agent · {name}',
|
||||
|
|
@ -21,4 +22,7 @@ export default {
|
|||
approveSession: '本会话内批准',
|
||||
reject: '拒绝',
|
||||
feedback: '+反馈',
|
||||
approvePlan: '批准 plan',
|
||||
revise: '修改',
|
||||
rejectAndExit: '拒绝并退出',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ export interface ToolCall {
|
|||
output?: string[]; // shown line by line when expanded
|
||||
media?: ToolMedia;
|
||||
defaultExpanded?: boolean;
|
||||
/** Absolute path of the plan file (ExitPlanMode only) — rendered as a
|
||||
* clickable link that opens the plan in the file preview. */
|
||||
planPath?: string;
|
||||
}
|
||||
|
||||
export interface ToolMedia {
|
||||
|
|
@ -159,6 +162,12 @@ export type ApprovalBlock =
|
|||
| { kind: 'search'; query: string; scope?: string }
|
||||
| { kind: 'invocation'; kind2: string; name: string; description?: string }
|
||||
| { kind: 'todo'; items: { title: string; status: string }[] }
|
||||
| {
|
||||
kind: 'plan_review';
|
||||
plan: string;
|
||||
path?: string;
|
||||
options?: { label: string; description?: string }[];
|
||||
}
|
||||
| { kind: 'generic'; summary: string };
|
||||
|
||||
export type TurnRole = 'user' | 'assistant' | 'compaction';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue