refactor(web): extract composer text + draft persistence into a composable (#1031)

Move the composer's text ref, textarea ref, autosize helper, the per-session
draft load/save watchers, and the loadForEdit handle into useComposerDraft.
The returned text/textareaRef/autosize refs are passed straight through to the
history / slash / mention composables as their deps, so the rest of the
component is unchanged.

Composer.vue: 1987 -> 1937 lines. Adds unit tests for useComposerDraft. No
behavior change.
This commit is contained in:
qer 2026-06-23 19:14:50 +08:00 committed by GitHub
parent a753b0535e
commit 2bfd6860e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 188 additions and 56 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Extract the composer's text state and per-session draft persistence into a reusable composable.

View file

@ -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';
@ -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
@ -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
@ -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 {

View file

@ -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 };
}

View file

@ -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');
});
});