diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts index 805c1f32f6..1755eafe10 100644 --- a/apps/vscode/shared/bridge.ts +++ b/apps/vscode/shared/bridge.ts @@ -37,6 +37,7 @@ export const Methods = { ResetSession: "resetSession", SetPlanMode: "setPlanMode", SteerChat: "steerChat", + UndoChat: "undoChat", RespondApproval: "respondApproval", GetKimiSessions: "getKimiSessions", @@ -96,6 +97,7 @@ export const Events = { FileChangesUpdated: "fileChangesUpdated", RollbackInput: "rollbackInput", LoginUrl: "loginUrl", + ConversationHistoryChanged: "conversationHistoryChanged", } as const; const rpcMethods = new Set(Object.values(Methods)); @@ -183,6 +185,12 @@ function validateParams(method: RpcMethod, params: unknown): boolean { return hasBoolean(params, "enabled"); case Methods.SteerChat: return isPlainObject(params) && isContent(params["content"]); + case Methods.UndoChat: + return params === undefined || ( + isPlainObject(params) + && (params["count"] === undefined + || (Number.isInteger(params["count"]) && (params["count"] as number) >= 1)) + ); case Methods.GetProjectFiles: return params === undefined || ( isPlainObject(params) diff --git a/apps/vscode/src/handlers/chat.handler.ts b/apps/vscode/src/handlers/chat.handler.ts index 21608381e2..ee2441eb51 100644 --- a/apps/vscode/src/handlers/chat.handler.ts +++ b/apps/vscode/src/handlers/chat.handler.ts @@ -6,6 +6,7 @@ import type { ApprovalResponse, ContentPart } from "../../shared/legacy-sdk"; import { getUserMessage } from "../../shared/errors"; import type { ErrorPhase } from "../../shared/types"; import { VSCodeSettings } from "../config/vscode-settings"; +import { normalizeEffort } from "../runtime/kimi-runtime"; import type { SessionRuntime } from "../runtime/session-runtime"; import { isWorkspacePathContained, relativeWorkspacePath } from "../utils/workspace-path"; import { parseHostSlashCommand, runHostSlashCommand } from "./slash-command"; @@ -101,11 +102,23 @@ const streamChat: Handler = async (params, } try { + // Attach no longer overwrites session modes with the configured defaults + // (resumed sessions keep their own), so apply the model/effort that the + // composer submitted with this prompt before the turn starts. const status = await runtime.session.getStatus(); + let model = status.model; + if (params.model && model !== params.model) { + await runtime.session.setModel(params.model); + model = params.model; + } + const effort = normalizeEffort(params.effort ?? (params.thinking === true ? "on" : "off")); + if (status.thinkingEffort !== effort) { + await runtime.session.setThinking(effort); + } if (params.planMode !== undefined && status.planMode !== params.planMode) { await runtime.session.setPlanMode(params.planMode); } - runtime.announceSessionStart(status.model); + runtime.announceSessionStart(model); } catch (error) { emitCaughtError(ctx, error, "preflight", runtime.id); return { done: false }; @@ -133,7 +146,10 @@ const streamChat: Handler = async (params, const abortChat: Handler = async (_, ctx) => { const runtime = ctx.getSession(); - if (runtime !== undefined) await runtime.cancel(); + // Do not claim an abort when there is no runtime to cancel — the webview + // would otherwise show the task as stopped while the engine keeps running. + if (runtime === undefined) return { aborted: false }; + await runtime.cancel(); return { aborted: true }; }; @@ -160,6 +176,17 @@ const steerChat: Handler<{ content: string | ContentPart[] }, { ok: boolean }> = return { ok: true }; }; +const undoChat: Handler<{ count?: number }, { ok: boolean }> = async (params, ctx) => { + const runtime = ctx.getSession(); + if (runtime === undefined) throw new Error("There is no conversation to undo."); + const requested = Number(params?.count); + const count = Number.isFinite(requested) && requested >= 1 ? Math.floor(requested) : 1; + // The runtime broadcasts ConversationHistoryChanged to every subscribed + // view on success; each view rehydrates via LoadKimiSessionHistory. + await runtime.undoHistory(count); + return { ok: true }; +}; + const resetSession: Handler = async (_, ctx) => { const runtime = ctx.getSession(); if (runtime !== undefined) injectedEditorContextSessions.delete(runtime.id); @@ -175,6 +202,7 @@ export const chatHandlers: Record> = { [Methods.RespondQuestion]: respondQuestion, [Methods.SetPlanMode]: setPlanMode, [Methods.SteerChat]: steerChat, + [Methods.UndoChat]: undoChat, [Methods.ResetSession]: resetSession, }; diff --git a/apps/vscode/src/runtime/kimi-runtime.ts b/apps/vscode/src/runtime/kimi-runtime.ts index 9da8e97543..9c5a9e5444 100644 --- a/apps/vscode/src/runtime/kimi-runtime.ts +++ b/apps/vscode/src/runtime/kimi-runtime.ts @@ -256,21 +256,18 @@ async function applySessionSettings( legacyApproval: LegacyApprovalFlags, ): Promise { const status = await session.getStatus(); - if (options.model && status.model !== options.model) { - await session.setModel(options.model); - } - // Thinking effort is applied only when the session is created (see - // openSession). An existing session keeps its own effort — the global - // config value is a default for new sessions, matching CLI/TUI resume - // semantics. Effort changes made in the picker reach the active session - // through the SaveConfig handler instead. + // Model and thinking effort are applied only when the session is created + // (see openSession). An existing session keeps its own — the global config + // values are defaults for new sessions, matching CLI/TUI resume semantics. + // Changes made in the pickers reach the active session through the + // SaveConfig handler instead. const permission = corePermissionForLegacyApproval(legacyApproval); if (status.permission !== permission) { await session.setPermission(permission); } } -function normalizeEffort(effort: string): ThinkingEffort { +export function normalizeEffort(effort: string): ThinkingEffort { return (effort.trim() || "off") as ThinkingEffort; } diff --git a/apps/vscode/src/runtime/session-runtime.ts b/apps/vscode/src/runtime/session-runtime.ts index 45d60ac500..d9e8c95f47 100644 --- a/apps/vscode/src/runtime/session-runtime.ts +++ b/apps/vscode/src/runtime/session-runtime.ts @@ -22,6 +22,7 @@ import { legacyApprovalMetadata, type LegacyApprovalFlags, } from "./legacy-approval"; +import { replaySessionToWebviewEvents } from "./replay-adapter"; import { ReverseRpcController } from "./reverse-rpc"; export type RuntimeBroadcast = (event: string, data: unknown, webviewId?: string) => void; @@ -327,7 +328,11 @@ export class SessionRuntime { } async cancel(): Promise { - if (this.closed || !this.hasActiveWork) return; + // Always reach the engine, even when the host believes nothing is active. + // The host-side bookkeeping can drift from engine truth after an abnormal + // error path; session.cancel() is a harmless no-op when the engine is + // idle, but it is the only way to recover a turn the host lost track of. + if (this.closed) return; this.reverseRpc.cancelAll("Turn cancelled"); const cancellingHostAction = this.hostActionActive; const hostActionId = this.activeHostActionId; @@ -347,6 +352,46 @@ export class SessionRuntime { if (failure !== undefined) throw failure.reason; } + /** + * Drop the last `count` user turns from the conversation, matching the + * CLI's /undo. Runs as one exclusive operation: undo, a single history + * reload, and one replay computation. The fresh transcript is then pushed + * to every subscribed view in the broadcast payload itself — views apply + * it locally and never call back into the engine, so nothing can race + * reloads or re-attach a view to the wrong session. A failed reload is + * reported distinctly: the undo already landed, so it must not look like + * a plain "undo failed". + */ + async undoHistory(count: number): Promise { + this.ensureOpen(); + if (this.isBusy) { + throw new Error("Wait for the current response to finish before undoing."); + } + this.exclusiveActionActive = true; + try { + await this.session.undoHistory(count); + try { + await this.session.reloadSession(); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Undo applied, but refreshing the conversation failed: ${detail}. Use Reset Kimi to reconcile.`, + { cause: error }, + ); + } + const resumeState = this.session.getResumeState(); + if (resumeState?.agents["main"] === undefined) { + throw new Error("Session history is unavailable."); + } + const events = replaySessionToWebviewEvents(resumeState, this.id); + for (const webviewId of this.webviewIds) { + this.broadcast(Events.ConversationHistoryChanged, { sessionId: this.id, events }, webviewId); + } + } finally { + this.exclusiveActionActive = false; + } + } + /** * Stop any in-flight work, wait for its terminal event, and keep new turns * out until the supplied operation finishes. Forking uses this so it reads a diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index a877efc600..88bbbdc597 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -130,6 +130,37 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () => expect(showLogs).not.toHaveBeenCalled(); }); + it("reports aborted: false when the view has no runtime to cancel", async () => { + const result = await bridge.handle({ id: "rpc-1", method: Methods.AbortChat }, "view-1"); + + expect(result).toEqual({ id: "rpc-1", result: { aborted: false } }); + }); + + it("cancels the view's runtime when aborting a chat", async () => { + const cancel = vi.fn(async () => undefined); + vi.spyOn(bridge.runtime, "getSessionForView").mockReturnValue({ cancel } as never); + + const result = await bridge.handle({ id: "rpc-1", method: Methods.AbortChat }, "view-1"); + + expect(result).toEqual({ id: "rpc-1", result: { aborted: true } }); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("rejects undoChat when the view has no runtime", async () => { + const result = await bridge.handle({ id: "rpc-1", method: Methods.UndoChat }, "view-1"); + + expect(result).toEqual({ id: "rpc-1", error: "There is no conversation to undo." }); + }); + + it("rejects an invalid undoChat count at the boundary", async () => { + const result = await bridge.handle( + { id: "rpc-1", method: Methods.UndoChat, params: { count: 0 } }, + "view-1", + ); + + expect(result).toEqual({ id: "rpc-1", error: "Invalid bridge params for method: undoChat" }); + }); + it.each(["missingMethod", "toString", "constructor", "__proto__"])( "does not dispatch the unknown or prototype method %s", async (method) => { diff --git a/apps/vscode/test/kimi-harness.integration.test.ts b/apps/vscode/test/kimi-harness.integration.test.ts index 36c9505735..ca4e08f92d 100644 --- a/apps/vscode/test/kimi-harness.integration.test.ts +++ b/apps/vscode/test/kimi-harness.integration.test.ts @@ -22,6 +22,9 @@ vi.mock("vscode", () => ({ showWarningMessage: async () => undefined, showTextDocument: async () => undefined, }, + workspace: { + getConfiguration: () => ({ get: (_key: string, fallback: unknown) => fallback }), + }, })); import { @@ -35,6 +38,7 @@ import { type UpdateMCPServerRequest, } from "../shared/legacy-sdk"; import { configHandlers } from "../src/handlers/config.handler"; +import { chatHandlers } from "../src/handlers/chat.handler"; import { mcpHandlers } from "../src/handlers/mcp.handler"; import { parseHostSlashCommand, runHostSlashCommand } from "../src/handlers/slash-command"; import type { HandlerContext } from "../src/handlers/types"; @@ -80,7 +84,7 @@ afterEach(async () => { } }); -async function createRuntimeRig(): Promise { +async function createRuntimeRig(extraAliases: readonly string[] = []): Promise { const rootDir = await mkdtemp(join(tmpdir(), "kimi-vscode-harness-")); const homeDir = join(rootDir, "home"); const workDir = join(rootDir, "workspace"); @@ -94,7 +98,7 @@ async function createRuntimeRig(): Promise { await provider.close(); }; - await writeProviderConfig(homeDir, `${provider.baseUrl}/v1`); + await writeProviderConfig(homeDir, `${provider.baseUrl}/v1`, extraAliases); const version = await readExtensionVersion(); const broadcasts: BroadcastRecord[] = []; const logs: LogRecord[] = []; @@ -184,7 +188,23 @@ async function readExtensionVersion(): Promise { return parsed.version; } -async function writeProviderConfig(homeDir: string, baseUrl: string): Promise { +async function writeProviderConfig( + homeDir: string, + baseUrl: string, + extraAliases: readonly string[] = [], +): Promise { + const extra = extraAliases + .map( + (alias) => ` +[models."${alias}"] +provider = "local" +model = "mock-model" +max_context_size = 128000 +capabilities = ["thinking"] +support_efforts = ["low", "high"] +`, + ) + .join("\n"); await writeFile( join(homeDir, "config.toml"), `default_model = "${MODEL_ALIAS}" @@ -198,7 +218,7 @@ api_key = "${PROVIDER_TOKEN}" provider = "local" model = "mock-model" max_context_size = 128000 - +${extra} [loop_control] max_retries_per_step = 1 `, @@ -270,6 +290,30 @@ async function openRuntimeSession(rig: RuntimeRig, sessionId?: string, yoloMode }); } +function streamChatContext(rig: RuntimeRig): HandlerContext { + return { + workDir: rig.workDir, + webviewId: "view-1", + broadcast: (event: string, data: unknown, webviewId?: string) => { + rig.broadcasts.push({ event, data, webviewId }); + }, + getOrCreateSession: async (model: string, effort: string, sessionId?: string) => + rig.runtime.openSession({ + webviewId: "view-1", + workDir: rig.workDir, + ...(sessionId === undefined ? {} : { sessionId }), + model, + effort, + yoloMode: false, + }), + getSession: () => rig.runtime.getSessionForView("view-1"), + saveAllDirty: async () => undefined, + logError: (message: string, error: unknown) => { + rig.logs.push({ message, error }); + }, + } as unknown as HandlerContext; +} + function streamEvents(broadcasts: readonly BroadcastRecord[]): unknown[] { return broadcasts .filter((record) => record.event === Events.StreamEvent) @@ -1018,6 +1062,74 @@ describe("VS Code Kimi harness integration (shares one in-process SDK home)", () await expect(runtime.session.getContext()).resolves.toEqual({ history: [], tokenCount: 0 }); }); + it("undoes the last user turn on a real session", async () => { + const rig = await createRuntimeRig(); + routeSuccessfulPrompt(rig.provider); + const runtime = await openRuntimeSession(rig); + await expect(runtime.prompt("hello")).resolves.toEqual({ status: "finished" }); + + await runtime.undoHistory(1); + + await expect(runtime.session.getContext()).resolves.toMatchObject({ history: [] }); + }); + + it("applies the composer-submitted model before the turn starts", async () => { + const rig = await createRuntimeRig(["vscode-alt"]); + routeSuccessfulPrompt(rig.provider); + const runtime = await openRuntimeSession(rig); + + const result = await chatHandlers[Methods.StreamChat]!( + { content: "hi", model: "vscode-alt", effort: "high" }, + streamChatContext(rig), + ); + + expect(result).toEqual({ done: true }); + await expect(runtime.session.getStatus()).resolves.toMatchObject({ + model: "vscode-alt", + thinkingEffort: "high", + }); + }); + + it("returns a fresh replay when undoing in a session created during this process", async () => { + const rig = await createRuntimeRig(); + routeSuccessfulPrompt(rig.provider); + const runtime = await openRuntimeSession(rig); + await expect(runtime.prompt("hello")).resolves.toEqual({ status: "finished" }); + + const result = await chatHandlers[Methods.UndoChat]!( + {}, + { getSession: () => runtime, logError: () => undefined } as unknown as HandlerContext, + ); + + expect(result).toEqual({ ok: true }); + const notice = rig.broadcasts.find((record) => record.event === Events.ConversationHistoryChanged); + expect(notice).toMatchObject({ webviewId: "view-1" }); + const events = (notice?.data as { events: Array<{ type: string }> }).events; + expect(Array.isArray(events)).toBe(true); + expect(events.some((event) => event.type === "TurnBegin")).toBe(false); + await expect(runtime.session.getContext()).resolves.toMatchObject({ history: [] }); + }); + + it("does not replay the pre-undo conversation when undoing a resumed session", async () => { + const rig = await createRuntimeRig(); + routeSuccessfulPrompt(rig.provider); + const created = await openRuntimeSession(rig); + await expect(created.prompt("hello")).resolves.toEqual({ status: "finished" }); + await rig.runtime.detachView("view-1"); + + const runtime = await openRuntimeSession(rig, created.id); + const result = await chatHandlers[Methods.UndoChat]!( + {}, + { getSession: () => runtime, logError: () => undefined } as unknown as HandlerContext, + ); + + expect(result).toEqual({ ok: true }); + const notice = rig.broadcasts.find((record) => record.event === Events.ConversationHistoryChanged); + expect(notice).toMatchObject({ webviewId: "view-1" }); + expect((notice?.data as { sessionId: string }).sessionId).toBe(created.id); + await expect(runtime.session.getContext()).resolves.toMatchObject({ history: [] }); + }); + it("toggles plan mode through the public session without calling the model", async () => { const rig = await createRuntimeRig(); const runtime = await openRuntimeSession(rig); diff --git a/apps/vscode/test/kimi-runtime.test.ts b/apps/vscode/test/kimi-runtime.test.ts index 8133c2916e..6a86f7f2d1 100644 --- a/apps/vscode/test/kimi-runtime.test.ts +++ b/apps/vscode/test/kimi-runtime.test.ts @@ -343,13 +343,14 @@ describe("Kimi runtime (owns shared SDK sessions for Webviews)", () => { expect(boundary.handlerInstallations).toEqual({ approval: 1, question: 1 }); }); - it("updates the SDK model when the resumed session has a different model", async () => { + it("preserves the resumed session's model instead of reapplying the configured default", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession("saved-1", "/workspace", { model: "old-model" }); - await runtime.openSession(openOptions({ sessionId: "saved-1", model: "new-model" })); + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", model: "new-model" })); - expect(session.setModels).toEqual(["new-model"]); + expect(session.setModels).toEqual([]); + await expect(opened.session.getStatus()).resolves.toMatchObject({ model: "old-model" }); }); it("preserves the resumed session's thinking effort instead of reapplying the configured default", async () => { diff --git a/apps/vscode/test/session-runtime.test.ts b/apps/vscode/test/session-runtime.test.ts index 6caadfabb1..ae8b9bef08 100644 --- a/apps/vscode/test/session-runtime.test.ts +++ b/apps/vscode/test/session-runtime.test.ts @@ -45,6 +45,9 @@ interface FakeSessionBoundary { readonly subscriptionCount: () => number; readonly cancelCount: () => number; readonly cancelCompactionCount: () => number; + readonly undoCounts: readonly number[]; + readonly reloadCount: () => number; + rejectNextReload(error: Error): void; readonly closeCount: () => number; emit(event: Event): void; rejectNextPrompt(error: Error): void; @@ -69,6 +72,9 @@ function createFakeSession(): FakeSessionBoundary { let subscriptions = 0; let cancellations = 0; let compactionCancellations = 0; + const undoCounts: number[] = []; + let reloads = 0; + let nextReloadError: Error | undefined; let closes = 0; let permission: PermissionMode = "manual"; @@ -115,6 +121,49 @@ function createFakeSession(): FakeSessionBoundary { async cancelCompaction() { compactionCancellations += 1; }, + async undoHistory(count: number) { + undoCounts.push(count); + }, + async reloadSession() { + if (nextReloadError !== undefined) { + const error = nextReloadError; + nextReloadError = undefined; + throw error; + } + reloads += 1; + return summary; + }, + getResumeState() { + return { + sessionMetadata: { agents: {} }, + agents: { + main: { + type: "main", + config: { + cwd: "/workspace", + modelAlias: "test-model", + modelCapabilities: { + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: true, + max_context_tokens: 128_000, + }, + thinkingEffort: "off", + systemPrompt: "", + }, + context: { history: [], tokenCount: 0 }, + replay: [], + permission: { mode: "manual", rules: [] }, + plan: null, + usage: {}, + tools: [], + background: [], + }, + }, + }; + }, async getStatus() { return { thinkingEffort: "off", @@ -152,6 +201,11 @@ function createFakeSession(): FakeSessionBoundary { subscriptionCount: () => subscriptions, cancelCount: () => cancellations, cancelCompactionCount: () => compactionCancellations, + undoCounts, + reloadCount: () => reloads, + rejectNextReload(error: Error) { + nextReloadError = error; + }, closeCount: () => closes, emit(event) { for (const listener of listeners) listener(event); @@ -508,6 +562,79 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", () expect(sdk.cancelCount()).toBe(1); }); + it("still reaches the SDK cancel when the host lost track of active work", async () => { + const { runtime, sdk } = createRuntime(); + + await runtime.cancel(); + + expect(sdk.cancelCount()).toBe(1); + }); + + it("forwards undoHistory to the SDK while the session is idle and pushes the fresh transcript", async () => { + const { runtime, sdk, broadcasts } = createRuntime(); + + await runtime.undoHistory(2); + + expect([...sdk.undoCounts]).toEqual([2]); + expect(sdk.reloadCount()).toBe(1); + const notice = broadcasts.find((record) => record.event === Events.ConversationHistoryChanged); + expect(notice).toMatchObject({ webviewId: "view-1" }); + expect((notice?.data as { sessionId: string; events: unknown[] }).sessionId).toBe("session-1"); + expect(Array.isArray((notice?.data as { events: unknown[] }).events)).toBe(true); + }); + + it("marks the undo as applied when only the post-undo refresh fails, then releases the guard", async () => { + const { runtime, sdk, broadcasts } = createRuntime(); + sdk.rejectNextReload(new Error("provider broke")); + + await expect(runtime.undoHistory(1)).rejects.toThrow( + "Undo applied, but refreshing the conversation failed: provider broke", + ); + expect(broadcasts).not.toContainEqual( + expect.objectContaining({ event: Events.ConversationHistoryChanged }), + ); + + await runtime.undoHistory(1); + expect([...sdk.undoCounts]).toEqual([1, 1]); + expect(broadcasts).toContainEqual( + expect.objectContaining({ event: Events.ConversationHistoryChanged, webviewId: "view-1" }), + ); + }); + + it("rejects undoHistory while a response is still generating", async () => { + const { runtime, sdk } = createRuntime(); + void runtime.prompt("hello"); + + await expect(runtime.undoHistory(1)).rejects.toThrow( + "Wait for the current response to finish before undoing.", + ); + expect(sdk.undoCounts).toEqual([]); + }); + + it("rejects a second undo while the first one is still running", async () => { + const { runtime, sdk } = createRuntime(); + let releaseUndo!: () => void; + const gate = new Promise((resolve) => { + releaseUndo = resolve; + }); + const fakeSession = sdk.session as unknown as { undoHistory: (count: number) => Promise }; + const original = fakeSession.undoHistory.bind(fakeSession); + let calls = 0; + fakeSession.undoHistory = (count: number) => { + calls += 1; + return calls === 1 ? gate.then(() => original(count)) : original(count); + }; + + const first = runtime.undoHistory(1); + await expect(runtime.undoHistory(1)).rejects.toThrow( + "Wait for the current response to finish before undoing.", + ); + + releaseUndo(); + await first; + expect(calls).toBe(1); + }); + it("converts legacy media keys when steering an active response", async () => { const { runtime, sdk } = createRuntime(); void runtime.prompt("hello"); diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx index 4a343fe896..407877eeeb 100644 --- a/apps/vscode/webview-ui/src/App.tsx +++ b/apps/vscode/webview-ui/src/App.tsx @@ -46,6 +46,19 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) { toast.error(error instanceof Error ? error.message : String(error)); }); }), + // The runtime pushes the freshly replayed transcript with this event + // (e.g. after undo); views just apply it locally — no round trip back + // into the engine that could re-attach this view to a stale session. + bridge.on( + Events.ConversationHistoryChanged, + ({ sessionId: changedSessionId, events }: { sessionId: string; events: UIStreamEvent[] }) => { + const store = useChatStore.getState(); + if (store.sessionId !== changedSessionId) return; + void store.loadSession(changedSessionId, events).catch((error: unknown) => { + toast.error(`Failed to reload the conversation: ${error instanceof Error ? error.message : String(error)}`); + }); + }, + ), ]; return () => unsubs.forEach((u) => u()); }, [setMCPServers, setExtensionConfig, startNewConversation]); diff --git a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx index 5d1b09cd64..629af2c79e 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx +++ b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx @@ -1,6 +1,6 @@ import { Fragment, useRef, useMemo, useState, useEffect, useCallback } from "react"; import { useMemoizedFn } from "ahooks"; -import { IconSend, IconPlayerStop, IconChevronDown, IconPlus } from "@tabler/icons-react"; +import { IconSend, IconPlayerStop, IconChevronDown, IconPlus, IconArrowBackUp } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -50,7 +50,7 @@ export function InputArea({ onAuthAction }: InputAreaProps) { const [cursorPos, setCursorPos] = useState(0); const [previewMedia, setPreviewMedia] = useState(null); - const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, planMode } = useChatStore(); + const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, planMode, undoLastTurn, sessionId, messages } = useChatStore(); const { currentModel, thinkingEffort, updateModel, toggleThinking, selectThinkingEffort, models, extensionConfig, getCurrentThinkingMode } = useSettingsStore(); const isProcessing = hasProcessingMedia(); @@ -463,6 +463,21 @@ export function InputArea({ onAuthAction }: InputAreaProps) {
+ + + + + Undo last turn + +