mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 14:16:22 +00:00
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:
parent
a753b0535e
commit
2bfd6860e4
4 changed files with 188 additions and 56 deletions
5
.changeset/web-extract-composer-draft.md
Normal file
5
.changeset/web-extract-composer-draft.md
Normal 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.
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
71
apps/kimi-web/src/composables/useComposerDraft.ts
Normal file
71
apps/kimi-web/src/composables/useComposerDraft.ts
Normal 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 };
|
||||
}
|
||||
106
apps/kimi-web/test/composer-draft.test.ts
Normal file
106
apps/kimi-web/test/composer-draft.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue