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-extract-composer-draft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Extract the composer's text state and per-session draft persistence into a reusable composable.
62 changes: 6 additions & 56 deletions apps/kimi-web/src/components/Composer.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!-- apps/kimi-web/src/components/Composer.vue -->
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import SlashMenu from './SlashMenu.vue';
import MentionMenu from './MentionMenu.vue';
Expand All @@ -9,10 +9,10 @@ import type { FileItem } from './MentionMenu.vue';
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../types';
import type { AppModel, AppSkill, ThinkingLevel } from '../api/types';
import { modelThinkingAvailability } from '../lib/modelThinking';
import { draftStorageKey, safeGetString, safeRemove, safeSetString } from '../lib/storage';
import { useInputHistory } from '../composables/useInputHistory';
import { useSlashMenu } from '../composables/useSlashMenu';
import { useMentionMenu } from '../composables/useMentionMenu';
import { useComposerDraft } from '../composables/useComposerDraft';

// ---------------------------------------------------------------------------
// Attachment state
Expand Down Expand Up @@ -101,48 +101,12 @@ const emit = defineEmits<{
const { t } = useI18n();

// ---------------------------------------------------------------------------
// Textarea
// Textarea + per-session draft persistence — see useComposerDraft.
// ---------------------------------------------------------------------------

// Unsent-draft persistence: the composer text is kept in localStorage PER
// SESSION, so switching away and back (or a page refresh) restores whatever the
// user was typing for that session. Cleared when the draft is sent/steered.
function loadDraft(sid: string | undefined): string {
return safeGetString(draftStorageKey(sid)) ?? '';
}
function saveDraft(sid: string | undefined, value: string): void {
const key = draftStorageKey(sid);
if (value) safeSetString(key, value);
else safeRemove(key);
}

const text = ref(loadDraft(props.sessionId));
const textareaRef = ref<HTMLTextAreaElement | null>(null);

function autosize(): void {
const el = textareaRef.value;
if (!el) return;
el.style.removeProperty('height');
}

watch(text, (value) => {
void nextTick(autosize);
// Persist the live draft for the current session (empty clears the entry).
saveDraft(props.sessionId, value);
const { text, textareaRef, autosize, loadForEdit } = useComposerDraft({
sessionId: () => props.sessionId,
});

// Switching sessions: stash the draft under the OLD session, then load the new
// session's draft into the box.
watch(
() => props.sessionId,
(newSid, oldSid) => {
if (newSid === oldSid) return;
saveDraft(oldSid, text.value);
text.value = loadDraft(newSid);
void nextTick(autosize);
},
);

// ---------------------------------------------------------------------------
// Sent-message history recall (shell-style ↑/↓). See useInputHistory for the
// implementation; the composer keeps the keydown orchestration (which also
Expand Down Expand Up @@ -362,21 +326,7 @@ onUnmounted(() => {
// Submit / keydown
// ---------------------------------------------------------------------------

/** Imperatively load text into the box for editing (used by "edit & resend the
last message" after an undo, or by the dock queue panel when the user edits
a queued prompt). Focuses with the caret at the end. */
function loadForEdit(value: string): void {
text.value = value;
void nextTick(() => {
const el = textareaRef.value;
if (!el) return;
el.focus();
const pos = value.length;
el.setSelectionRange(pos, pos);
autosize();
});
}

// loadForEdit comes from useComposerDraft (it lives next to the text state).
defineExpose({ loadForEdit });

function handleSubmit(): void {
Expand Down
71 changes: 71 additions & 0 deletions apps/kimi-web/src/composables/useComposerDraft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// apps/kimi-web/src/composables/useComposerDraft.ts
import { nextTick, ref, watch } from 'vue';
import { draftStorageKey, safeGetString, safeRemove, safeSetString } from '../lib/storage';

export interface ComposerDraftDeps {
/** Active session id — scopes the persisted draft (getter for reactivity). */
sessionId: () => string | undefined;
}

/**
* The composer's text state plus its per-session unsent-draft persistence.
*
* The draft is kept in localStorage keyed by session, so switching away and back
* (or a page refresh) restores whatever the user was typing for that session; it
* is cleared when the draft is sent/steered. This composable owns the `text`
* and `textarea` refs, the `autosize` helper, the draft load/save watchers, and
* the imperative `loadForEdit` handle exposed to the parent.
*/
export function useComposerDraft(deps: ComposerDraftDeps) {
const { sessionId } = deps;

function loadDraft(sid: string | undefined): string {
return safeGetString(draftStorageKey(sid)) ?? '';
}
function saveDraft(sid: string | undefined, value: string): void {
const key = draftStorageKey(sid);
if (value) safeSetString(key, value);
else safeRemove(key);
}

const text = ref(loadDraft(sessionId()));
const textareaRef = ref<HTMLTextAreaElement | null>(null);

function autosize(): void {
const el = textareaRef.value;
if (!el) return;
el.style.removeProperty('height');
}

watch(text, (value) => {
void nextTick(autosize);
// Persist the live draft for the current session (empty clears the entry).
saveDraft(sessionId(), value);
});

// Switching sessions: stash the draft under the OLD session, then load the new
// session's draft into the box.
watch(sessionId, (newSid, oldSid) => {
if (newSid === oldSid) return;
saveDraft(oldSid, text.value);
text.value = loadDraft(newSid);
void nextTick(autosize);
});

/** Imperatively load text into the box for editing (used by "edit & resend the
last message" after an undo, or by the dock queue panel when the user edits
a queued prompt). Focuses with the caret at the end. */
function loadForEdit(value: string): void {
text.value = value;
void nextTick(() => {
const el = textareaRef.value;
if (!el) return;
el.focus();
const pos = value.length;
el.setSelectionRange(pos, pos);
autosize();
});
}

return { text, textareaRef, autosize, loadForEdit };
}
106 changes: 106 additions & 0 deletions apps/kimi-web/test/composer-draft.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { nextTick, ref } from 'vue';
import { useComposerDraft } from '../src/composables/useComposerDraft';
import { draftStorageKey } from '../src/lib/storage';

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);
},
};
}

function setup(initialSid: string | undefined) {
const sid = ref(initialSid);
const draft = useComposerDraft({ sessionId: () => sid.value });
return {
draft,
text: draft.text,
setSid: (next: string | undefined) => {
sid.value = next;
},
};
}

describe('useComposerDraft', () => {
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('loads the stored draft for the session on init', () => {
globalThis.localStorage.setItem(draftStorageKey('s1'), 'saved draft');
const { text } = setup('s1');
expect(text.value).toBe('saved draft');
});

it('starts empty when the session has no stored draft', () => {
const { text } = setup('s1');
expect(text.value).toBe('');
});

it('persists the draft when the text changes', async () => {
const { text } = setup('s1');
text.value = 'hello';
await nextTick();
expect(globalThis.localStorage.getItem(draftStorageKey('s1'))).toBe('hello');
});

it('clears the stored draft when the text is emptied', async () => {
globalThis.localStorage.setItem(draftStorageKey('s1'), 'x');
const { text } = setup('s1');
text.value = '';
await nextTick();
expect(globalThis.localStorage.getItem(draftStorageKey('s1'))).toBeNull();
});

it('saves the old draft and loads the new one on session switch', async () => {
const { text, setSid } = setup('s1');
text.value = 'draft-s1';
await nextTick();
globalThis.localStorage.setItem(draftStorageKey('s2'), 'draft-s2');

setSid('s2');
await nextTick();

expect(globalThis.localStorage.getItem(draftStorageKey('s1'))).toBe('draft-s1');
expect(text.value).toBe('draft-s2');
});

it('loadForEdit replaces the text', () => {
const { draft } = setup('s1');
draft.loadForEdit('edit me');
expect(draft.text.value).toBe('edit me');
});
});
Loading