From 461246c62dc8a1c2f10c1b6b0200120da41a0911 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 16:37:26 +0800 Subject: [PATCH 1/3] fix(web): persist input history so recall works after the first message The composer has two mutually-exclusive instances: the empty-session composer and the docked composer. The first message of a new session is sent by the empty composer, which unmounts as soon as the first turn appears; the docked composer then mounted with an empty in-memory history, so ArrowUp did nothing until a second message was sent. The history was also lost on every page reload. Persist the history to localStorage as a single global list and re-read it on mount. Global (not per-session) because a new session has no id until after the first submit, so per-session keys would not line up across the empty -> docked handoff. Caps the list at 200 entries. Adds persistence-focused unit tests (surviving a remount, the 200-entry cap, and a malformed stored value). --- .changeset/fix-web-persist-input-history.md | 5 ++ .../src/composables/useInputHistory.ts | 24 +++++- apps/kimi-web/src/lib/storage.ts | 1 + apps/kimi-web/test/input-history.test.ts | 85 ++++++++++++++++++- 4 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-web-persist-input-history.md diff --git a/.changeset/fix-web-persist-input-history.md b/.changeset/fix-web-persist-input-history.md new file mode 100644 index 0000000000..6f81ed620e --- /dev/null +++ b/.changeset/fix-web-persist-input-history.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the composer's ↑/↓ input-history recall doing nothing right after the first message of a new session. The history is now persisted to localStorage and re-read on mount, so the docked composer no longer starts empty when it takes over from the empty-session composer. diff --git a/apps/kimi-web/src/composables/useInputHistory.ts b/apps/kimi-web/src/composables/useInputHistory.ts index 134e1e30a0..fcb8cc754b 100644 --- a/apps/kimi-web/src/composables/useInputHistory.ts +++ b/apps/kimi-web/src/composables/useInputHistory.ts @@ -1,5 +1,15 @@ // apps/kimi-web/src/composables/useInputHistory.ts import { nextTick, ref, type Ref } from 'vue'; +import { STORAGE_KEYS, safeGetJson, safeSetJson } from '../lib/storage'; + +/** Cap the persisted history so storage can't grow without bound. */ +const MAX_HISTORY = 200; + +function loadHistory(): string[] { + const stored = safeGetJson(STORAGE_KEYS.inputHistory); + if (!Array.isArray(stored)) return []; + return stored.filter((s): s is string => typeof s === 'string' && s.length > 0); +} export interface InputHistoryDeps { /** The live composer text — recalled entries overwrite it. */ @@ -18,6 +28,14 @@ export interface InputHistoryDeps { * they started browsing. Any manual edit drops out of browsing mode (see * `resetBrowsing`, called from the composer's input handler). * + * The history is persisted to localStorage (one global list). The composer has + * two mutually-exclusive instances — the empty-session composer and the docked + * composer — and the first message of a new session is sent by the empty + * composer, which unmounts as soon as the first turn appears. Persisting (and + * re-reading on mount) is what lets the docked composer recall that first + * message instead of starting from an empty list. A single global list also + * sidesteps the fact that a new session has no id until after the first submit. + * * The composer keeps the keydown orchestration (which also juggles the slash * and mention menus); this composable owns only the history list, the browsing * cursor, and the textarea caret/selection work needed to apply a recalled @@ -26,7 +44,7 @@ export interface InputHistoryDeps { export function useInputHistory(deps: InputHistoryDeps) { const { text, textareaRef, autosize } = deps; - const inputHistory = ref([]); + const inputHistory = ref(loadHistory()); // -1 = browsing nothing (live draft). Otherwise an index into inputHistory. let historyIndex = -1; let draftBeforeHistory = ''; @@ -37,7 +55,9 @@ export function useInputHistory(deps: InputHistoryDeps) { if (!trimmed) return; // Skip consecutive duplicates so repeated sends don't pad the history. if (inputHistory.value.at(-1) === trimmed) return; - inputHistory.value = [...inputHistory.value, trimmed]; + const next = [...inputHistory.value, trimmed]; + inputHistory.value = next.length > MAX_HISTORY ? next.slice(-MAX_HISTORY) : next; + safeSetJson(STORAGE_KEYS.inputHistory, inputHistory.value); } function caretAtFirstLine(): boolean { diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts index a0241936e2..d1b5d5ca21 100644 --- a/apps/kimi-web/src/lib/storage.ts +++ b/apps/kimi-web/src/lib/storage.ts @@ -24,6 +24,7 @@ export const STORAGE_KEYS = { hiddenWorkspaces: 'kimi-web.hidden-workspaces', betaToc: 'kimi-web.beta-toc', notifyOnComplete: 'kimi-web.notify-on-complete', + inputHistory: 'kimi-web.input-history', // cross-file locale: 'kimi-locale', clientId: 'kimi-web.client-id', diff --git a/apps/kimi-web/test/input-history.test.ts b/apps/kimi-web/test/input-history.test.ts index 1fe294945b..32aee0372d 100644 --- a/apps/kimi-web/test/input-history.test.ts +++ b/apps/kimi-web/test/input-history.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ref, type Ref } from 'vue'; import { useInputHistory } from '../src/composables/useInputHistory'; +import { STORAGE_KEYS } from '../src/lib/storage'; interface MockTextarea { value: string; @@ -134,3 +135,85 @@ describe('useInputHistory — caretAtFirstLine', () => { expect(history.caretAtFirstLine()).toBe(true); }); }); + +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear: () => { + map.clear(); + }, + getItem: (key: string) => map.get(key) ?? null, + key: (index: number) => Array.from(map.keys())[index] ?? null, + removeItem: (key: string) => { + map.delete(key); + }, + setItem: (key: string, value: string) => { + map.set(key, value); + }, + }; +} + +describe('useInputHistory — persistence', () => { + let original: Storage | undefined; + + beforeEach(() => { + original = (globalThis as { localStorage?: Storage }).localStorage; + Object.defineProperty(globalThis, 'localStorage', { + value: memoryStorage(), + configurable: true, + writable: true, + }); + }); + + afterEach(() => { + if (original === undefined) { + delete (globalThis as { localStorage?: Storage }).localStorage; + } else { + Object.defineProperty(globalThis, 'localStorage', { + value: original, + configurable: true, + writable: true, + }); + } + }); + + it('writes each pushed entry to localStorage', () => { + const { history } = setup(); + history.push('hello'); + const stored = globalThis.localStorage.getItem(STORAGE_KEYS.inputHistory); + expect(stored).toBe(JSON.stringify(['hello'])); + }); + + it('a freshly mounted composable reads back the persisted history', () => { + const first = setup(); + first.history.push('a'); + first.history.push('b'); + + // Simulates the empty composer unmounting and the docked composer mounting. + const second = setup(); + second.history.recallOlder(); + expect(second.text.value).toBe('b'); + second.history.recallOlder(); + expect(second.text.value).toBe('a'); + }); + + it('trims to the newest 200 entries, dropping the oldest', () => { + const { text, history } = setup(); + for (let i = 0; i < 205; i++) history.push(`m${i}`); + + // Walk all the way back; the oldest kept entry must be m5 (m0..m4 dropped). + for (let i = 0; i < 200; i++) history.recallOlder(); + expect(text.value).toBe('m5'); + history.recallOlder(); // already at the oldest kept entry — must not move + expect(text.value).toBe('m5'); + }); + + it('ignores a malformed stored value and starts empty', () => { + globalThis.localStorage.setItem(STORAGE_KEYS.inputHistory, 'not-json'); + const { history } = setup(); + expect(history.hasHistory()).toBe(false); + }); +}); From 32669b8e09421d468e2862f7fcfedf42de7b7fa1 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 17:03:00 +0800 Subject: [PATCH 2/3] fix(web): record slash commands in input history too Move the history.push call ahead of the slash-command branch so that known commands (with or without args, e.g. /goal or /model) are recorded and can be recalled with ArrowUp, instead of only plain messages. Steer already pushed; only the submit slash path was missing it. --- .changeset/fix-web-persist-input-history.md | 2 +- apps/kimi-web/src/components/Composer.vue | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/fix-web-persist-input-history.md b/.changeset/fix-web-persist-input-history.md index 6f81ed620e..7e4f38bcc3 100644 --- a/.changeset/fix-web-persist-input-history.md +++ b/.changeset/fix-web-persist-input-history.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix the composer's ↑/↓ input-history recall doing nothing right after the first message of a new session. The history is now persisted to localStorage and re-read on mount, so the docked composer no longer starts empty when it takes over from the empty-session composer. +Fix the composer's ↑/↓ input-history recall doing nothing right after the first message of a new session. The history is now persisted to localStorage and re-read on mount, so the docked composer no longer starts empty when it takes over from the empty-session composer. Slash commands (with or without args) are now recorded in history too, so they can be recalled like plain messages. diff --git a/apps/kimi-web/src/components/Composer.vue b/apps/kimi-web/src/components/Composer.vue index 3f5b8cbb26..1b453b23d1 100644 --- a/apps/kimi-web/src/components/Composer.vue +++ b/apps/kimi-web/src/components/Composer.vue @@ -459,6 +459,11 @@ function handleSubmit(): void { if (!trimmed && readyAttachments.length === 0) return; + // Record for ↑/↓ recall before the slash branch so commands (with or without + // args) are recallable too, not just plain messages. `push` ignores empty / + // whitespace, so an image-only send adds nothing. + history.push(trimmed); + // If it's a known slash command, keep the optional tail as command input // instead of submitting it as normal chat text. This covers `/goal `, // `/swarm `, `/btw `, slash skills with args, and bare @@ -488,7 +493,6 @@ function handleSubmit(): void { } attachments.value = []; - history.push(trimmed); text.value = ''; slashOpen.value = false; mentionOpen.value = false; From aa83b9ab88972f44e6862ffbe4a8194eca616727 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 17:34:13 +0800 Subject: [PATCH 3/3] fix(web): record menu-selected slash commands in history Bare slash commands picked from the slash menu (e.g. /model, /login) go through selectSlashCommand and emit directly, never reaching handleSubmit, so they were not recorded even after the typed-slash fix. Push the command name before emitting. acceptsInput commands are still recorded later by handleSubmit together with their argument. --- .changeset/fix-web-persist-input-history.md | 2 +- apps/kimi-web/src/components/Composer.vue | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-web-persist-input-history.md b/.changeset/fix-web-persist-input-history.md index 7e4f38bcc3..05529c9386 100644 --- a/.changeset/fix-web-persist-input-history.md +++ b/.changeset/fix-web-persist-input-history.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix the composer's ↑/↓ input-history recall doing nothing right after the first message of a new session. The history is now persisted to localStorage and re-read on mount, so the docked composer no longer starts empty when it takes over from the empty-session composer. Slash commands (with or without args) are now recorded in history too, so they can be recalled like plain messages. +Fix the composer's ↑/↓ input-history recall doing nothing right after the first message of a new session. The history is now persisted to localStorage and re-read on mount, so the docked composer no longer starts empty when it takes over from the empty-session composer. Slash commands are now recorded too — both typed-and-submitted and ones picked from the slash menu — so they can be recalled like plain messages. diff --git a/apps/kimi-web/src/components/Composer.vue b/apps/kimi-web/src/components/Composer.vue index 1b453b23d1..f18e477465 100644 --- a/apps/kimi-web/src/components/Composer.vue +++ b/apps/kimi-web/src/components/Composer.vue @@ -185,6 +185,10 @@ function selectSlashCommand(item: SlashCommand): void { return; } text.value = ''; + // Menu-selected bare commands (e.g. /model, /login) reach here directly and + // never go through handleSubmit, so record them for ↑/↓ recall too. acceptsInput + // commands are pushed later by handleSubmit with their argument. + history.push(item.name); emit('command', item.name); }