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-persist-input-history.md
Original file line number Diff line number Diff line change
@@ -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. 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.
10 changes: 9 additions & 1 deletion apps/kimi-web/src/components/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -459,6 +463,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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Record slash-menu command selections in history

When a user runs a bare slash command from the open slash menu, the keydown handler calls selectSlashCommand and returns before handleSubmit; for non-acceptsInput commands such as /model or /login, that path emits the command directly. Since history is only pushed here, those common menu-selected slash commands still are not recallable even though typed-and-submitted slash commands are; consider pushing the selected command in selectSlashCommand before emitting it.

Useful? React with 👍 / 👎.


// 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 <task>`,
// `/swarm <task>`, `/btw <question>`, slash skills with args, and bare
Expand Down Expand Up @@ -488,7 +497,6 @@ function handleSubmit(): void {
}
attachments.value = [];

history.push(trimmed);
text.value = '';
slashOpen.value = false;
mentionOpen.value = false;
Expand Down
24 changes: 22 additions & 2 deletions apps/kimi-web/src/composables/useInputHistory.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>(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. */
Expand All @@ -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
Expand All @@ -26,7 +44,7 @@ export interface InputHistoryDeps {
export function useInputHistory(deps: InputHistoryDeps) {
const { text, textareaRef, autosize } = deps;

const inputHistory = ref<string[]>([]);
const inputHistory = ref(loadHistory());
// -1 = browsing nothing (live draft). Otherwise an index into inputHistory.
let historyIndex = -1;
let draftBeforeHistory = '';
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
85 changes: 84 additions & 1 deletion apps/kimi-web/test/input-history.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -134,3 +135,85 @@ describe('useInputHistory — caretAtFirstLine', () => {
expect(history.caretAtFirstLine()).toBe(true);
});
});

function memoryStorage(): Storage {
const map = new Map<string, string>();
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);
});
});
Loading