From 17df1fde9bf9abeab333130eb5b0eb180e61acc8 Mon Sep 17 00:00:00 2001 From: qer Date: Sun, 28 Jun 2026 21:42:04 +0800 Subject: [PATCH 1/8] feat(web): preserve open side panel across session switches --- .changeset/web-preserve-side-panel.md | 5 ++ .../src/composables/useDetailPanel.ts | 78 ++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 .changeset/web-preserve-side-panel.md diff --git a/.changeset/web-preserve-side-panel.md b/.changeset/web-preserve-side-panel.md new file mode 100644 index 000000000..44f268f2a --- /dev/null +++ b/.changeset/web-preserve-side-panel.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep the open side panel when switching between sessions in the web UI. diff --git a/apps/kimi-web/src/composables/useDetailPanel.ts b/apps/kimi-web/src/composables/useDetailPanel.ts index e92880be5..cbd82e5b2 100644 --- a/apps/kimi-web/src/composables/useDetailPanel.ts +++ b/apps/kimi-web/src/composables/useDetailPanel.ts @@ -261,6 +261,70 @@ export function useDetailPanel({ transition is disabled so the panel follows the pointer 1:1. */ const panelDragging = ref(false); + // --------------------------------------------------------------------------- + // Per-session panel snapshot (in-memory only). Switching sessions still closes + // the right-side detail layer, but for the transient panels whose content is + // re-derived from the session's turns (thinking / compaction / agent / + // toolDiff) or already stored per session (btw), we remember which one was + // open and restore it when the user switches back. + // + // File preview ('file') and git diff ('diff') are intentionally excluded: + // their content is tied to the active session's cwd / git state and is + // re-fetched on demand, so restoring them across sessions would be ambiguous. + // --------------------------------------------------------------------------- + type PanelSnapshot = + | { kind: 'thinking'; turnId: string; blockIndex: number } + | { kind: 'compaction'; turnId: string } + | { kind: 'agent'; turnId: string; blockIndex: number; memberId: string } + | { kind: 'toolDiff'; toolId: string } + | { kind: 'btw' }; + + const snapshotBySession = ref>({}); + + function captureSnapshot(): PanelSnapshot | null { + switch (detailTarget.value) { + case 'thinking': + return thinkingTarget.value ? { kind: 'thinking', ...thinkingTarget.value } : null; + case 'compaction': + return compactionTarget.value ? { kind: 'compaction', ...compactionTarget.value } : null; + case 'agent': + return agentTarget.value ? { kind: 'agent', ...agentTarget.value } : null; + case 'toolDiff': + return toolDiffToolId.value ? { kind: 'toolDiff', toolId: toolDiffToolId.value } : null; + case 'btw': + return { kind: 'btw' }; + default: + return null; + } + } + + function restoreSnapshot(snap: PanelSnapshot | undefined): void { + if (!snap) return; + switch (snap.kind) { + case 'thinking': + thinkingTarget.value = { turnId: snap.turnId, blockIndex: snap.blockIndex }; + detailTarget.value = 'thinking'; + break; + case 'compaction': + compactionTarget.value = { turnId: snap.turnId }; + detailTarget.value = 'compaction'; + break; + case 'agent': + agentTarget.value = { turnId: snap.turnId, blockIndex: snap.blockIndex, memberId: snap.memberId }; + detailTarget.value = 'agent'; + break; + case 'toolDiff': + toolDiffToolId.value = snap.toolId; + detailTarget.value = 'toolDiff'; + break; + case 'btw': + // Only re-open the BTW panel if this session still has a live side chat; + // the snapshot can outlive it if the user closed the side chat explicitly. + if (client.sideChatVisible.value) detailTarget.value = 'btw'; + break; + } + } + // Escape closes whichever transient right-side detail panel is open. function closeOpenSidePanel(): boolean { if (detailTarget.value === 'thinking' && thinkingVisible.value) { closeThinkingPanel(); return true; } @@ -273,7 +337,15 @@ export function useDetailPanel({ return false; } - watch(client.activeSessionId, () => { + watch(client.activeSessionId, (newId, oldId) => { + // Remember the leaving session's open panel (restorable kinds only) before + // the close calls below wipe the target refs. + if (oldId) { + const snap = captureSnapshot(); + if (snap) snapshotBySession.value[oldId] = snap; + else delete snapshotBySession.value[oldId]; + } + // Close everything for the incoming session (unchanged behavior). closeFilePreview(); closeThinkingPanel(); closeCompactionPanel(); @@ -281,6 +353,10 @@ export function useDetailPanel({ closeToolDiff(); closeDiffDetail(); hideSideChatPanel(); + // Restore the entering session's panel, if it had one. + if (newId) { + restoreSnapshot(snapshotBySession.value[newId]); + } }); return { From c9cdec539651398d322534e773dd811ad5f1debd Mon Sep 17 00:00:00 2001 From: qer Date: Sun, 28 Jun 2026 21:42:05 +0800 Subject: [PATCH 2/8] feat(web): scope composer input history to current session --- .changeset/web-session-input-history.md | 5 + .../kimi-web/src/components/chat/Composer.vue | 2 +- .../src/composables/useInputHistory.ts | 114 +++++++++++------- apps/kimi-web/test/input-history.test.ts | 60 +++++++-- 4 files changed, 131 insertions(+), 50 deletions(-) create mode 100644 .changeset/web-session-input-history.md diff --git a/.changeset/web-session-input-history.md b/.changeset/web-session-input-history.md new file mode 100644 index 000000000..c2215d909 --- /dev/null +++ b/.changeset/web-session-input-history.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Scope the web composer's up/down input history to the current session instead of sharing it across all sessions. diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 8c48a01f0..82d2d4c11 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -160,7 +160,7 @@ watch(() => props.sessionId, () => { // implementation; the composer keeps the keydown orchestration (which also // juggles the slash and mention menus). // --------------------------------------------------------------------------- -const history = useInputHistory({ text, textareaRef, autosize }); +const history = useInputHistory({ text, textareaRef, autosize, sessionId: () => props.sessionId }); // --------------------------------------------------------------------------- // Slash-command menu — see useSlashMenu for the implementation. The composer diff --git a/apps/kimi-web/src/composables/useInputHistory.ts b/apps/kimi-web/src/composables/useInputHistory.ts index fcb8cc754..b8f17fe9c 100644 --- a/apps/kimi-web/src/composables/useInputHistory.ts +++ b/apps/kimi-web/src/composables/useInputHistory.ts @@ -1,15 +1,27 @@ // apps/kimi-web/src/composables/useInputHistory.ts -import { nextTick, ref, type Ref } from 'vue'; -import { STORAGE_KEYS, safeGetJson, safeSetJson } from '../lib/storage'; +// Shell-style ↑/↓ recall of previously sent messages, scoped per session. +// +// `ArrowUp` on the first line steps back through older entries sent in the +// current session; `ArrowDown` walks forward again and ultimately restores the +// draft the user had before 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 as a `Record`. +// A draft session (no id yet — the empty-session composer before its first +// message is sent) does NOT record history: that first message is submitted +// before the session exists, so it is intentionally dropped rather than +// attributed to the wrong session. +// +// The composer keeps the keydown orchestration (which also juggles the slash +// and mention menus); this composable owns only the history map, the browsing +// cursor, and the textarea caret/selection work needed to apply a recalled +// entry. -/** Cap the persisted history so storage can't grow without bound. */ -const MAX_HISTORY = 200; +import { computed, nextTick, ref, watch, type Ref } from 'vue'; +import { STORAGE_KEYS, safeGetJson, safeSetJson } from '../lib/storage'; -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); -} +/** Cap each session's persisted history so storage can't grow without bound. */ +const MAX_HISTORY = 100; export interface InputHistoryDeps { /** The live composer text — recalled entries overwrite it. */ @@ -18,46 +30,56 @@ export interface InputHistoryDeps { textareaRef: Ref; /** Re-fit the textarea after its text changes. */ autosize: () => void; + /** Active session id — scopes the recalled history (getter for reactivity). */ + sessionId: () => string | undefined; } /** - * Shell-style ↑/↓ recall of previously sent messages. - * - * `ArrowUp` on the first line steps back through older entries; `ArrowDown` - * walks forward again and ultimately restores the draft the user had before - * 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 - * entry. + * Read the persisted history map, migrating the legacy global `string[]` format + * (pre per-session) into the current session on first sight. Migration is + * one-shot: once a sessioned map is written, the array branch never runs again. */ +function loadMap(sessionId: string | undefined): Record { + const raw = safeGetJson(STORAGE_KEYS.inputHistory); + if (Array.isArray(raw)) { + const list = raw.filter((s): s is string => typeof s === 'string' && s.length > 0); + // No session yet (empty-session composer): leave the legacy value in place + // so a later docked mount — which has a session id — can migrate it. + if (!sessionId || list.length === 0) return {}; + const capped = list.length > MAX_HISTORY ? list.slice(-MAX_HISTORY) : list; + const map = { [sessionId]: capped }; + safeSetJson(STORAGE_KEYS.inputHistory, map); + return map; + } + if (raw && typeof raw === 'object') { + return raw as Record; + } + return {}; +} + export function useInputHistory(deps: InputHistoryDeps) { - const { text, textareaRef, autosize } = deps; + const { text, textareaRef, autosize, sessionId } = deps; - const inputHistory = ref(loadHistory()); - // -1 = browsing nothing (live draft). Otherwise an index into inputHistory. + const historyMap = ref>(loadMap(sessionId())); + const currentList = computed(() => historyMap.value[sessionId() ?? ''] ?? []); + // -1 = browsing nothing (live draft). Otherwise an index into currentList. let historyIndex = -1; let draftBeforeHistory = ''; function push(entry: string): void { - const trimmed = entry.trim(); + const sid = sessionId(); historyIndex = -1; + // Draft sessions have no id yet — drop the entry (see file header). + if (!sid) return; + const trimmed = entry.trim(); if (!trimmed) return; + const list = historyMap.value[sid] ?? []; // Skip consecutive duplicates so repeated sends don't pad the history. - if (inputHistory.value.at(-1) === trimmed) return; - const next = [...inputHistory.value, trimmed]; - inputHistory.value = next.length > MAX_HISTORY ? next.slice(-MAX_HISTORY) : next; - safeSetJson(STORAGE_KEYS.inputHistory, inputHistory.value); + if (list.at(-1) === trimmed) return; + const next = [...list, trimmed]; + const capped = next.length > MAX_HISTORY ? next.slice(-MAX_HISTORY) : next; + historyMap.value = { ...historyMap.value, [sid]: capped }; + safeSetJson(STORAGE_KEYS.inputHistory, historyMap.value); } function caretAtFirstLine(): boolean { @@ -80,23 +102,25 @@ export function useInputHistory(deps: InputHistoryDeps) { } function recallOlder(): void { - if (inputHistory.value.length === 0) return; + const list = currentList.value; + if (list.length === 0) return; if (historyIndex === -1) { draftBeforeHistory = text.value; - historyIndex = inputHistory.value.length - 1; + historyIndex = list.length - 1; } else if (historyIndex > 0) { historyIndex -= 1; } else { return; // already at the oldest entry } - applyHistoryText(inputHistory.value[historyIndex]!); + applyHistoryText(list[historyIndex]!); } function recallNewer(): void { if (historyIndex === -1) return; - if (historyIndex < inputHistory.value.length - 1) { + const list = currentList.value; + if (historyIndex < list.length - 1) { historyIndex += 1; - applyHistoryText(inputHistory.value[historyIndex]!); + applyHistoryText(list[historyIndex]!); } else { historyIndex = -1; applyHistoryText(draftBeforeHistory); @@ -112,9 +136,15 @@ export function useInputHistory(deps: InputHistoryDeps) { } function hasHistory(): boolean { - return inputHistory.value.length > 0; + return currentList.value.length > 0; } + // Switching sessions: drop the browsing cursor so a recall in the new session + // starts from its own latest entry, not wherever the previous session left off. + watch(sessionId, () => { + historyIndex = -1; + }); + return { push, caretAtFirstLine, diff --git a/apps/kimi-web/test/input-history.test.ts b/apps/kimi-web/test/input-history.test.ts index 32aee0372..f4e221281 100644 --- a/apps/kimi-web/test/input-history.test.ts +++ b/apps/kimi-web/test/input-history.test.ts @@ -10,7 +10,7 @@ interface MockTextarea { setSelectionRange: (start: number, end: number) => void; } -function setup(initialText = '', caret = 0) { +function setup(initialText = '', caret = 0, sessionId: string | null = 'test-session') { const textarea: MockTextarea = { value: initialText, selectionStart: caret, @@ -22,7 +22,7 @@ function setup(initialText = '', caret = 0) { }; const text = ref(initialText); const textareaRef = ref(textarea as unknown as HTMLTextAreaElement) as Ref; - const history = useInputHistory({ text, textareaRef, autosize: () => {} }); + const history = useInputHistory({ text, textareaRef, autosize: () => {}, sessionId: () => sessionId ?? undefined }); return { text, textarea, history }; } @@ -54,6 +54,12 @@ describe('useInputHistory — push', () => { history.recallOlder(); // already oldest — must stay, not land on a second 'a' expect(text.value).toBe('a'); }); + + it('drops entries pushed without a session (draft / empty composer)', () => { + const { history } = setup('', 0, null); + history.push('hello'); + expect(history.hasHistory()).toBe(false); + }); }); describe('useInputHistory — recall', () => { @@ -180,11 +186,11 @@ describe('useInputHistory — persistence', () => { } }); - it('writes each pushed entry to localStorage', () => { + it('writes each pushed entry to localStorage under its session', () => { const { history } = setup(); history.push('hello'); const stored = globalThis.localStorage.getItem(STORAGE_KEYS.inputHistory); - expect(stored).toBe(JSON.stringify(['hello'])); + expect(stored).toBe(JSON.stringify({ 'test-session': ['hello'] })); }); it('a freshly mounted composable reads back the persisted history', () => { @@ -200,12 +206,30 @@ describe('useInputHistory — persistence', () => { expect(second.text.value).toBe('a'); }); - it('trims to the newest 200 entries, dropping the oldest', () => { + it('keeps histories of different sessions isolated', () => { + const a = setup('', 0, 'sess-a'); + a.history.push('from-a'); + const b = setup('', 0, 'sess-b'); + b.history.push('from-b'); + + // Re-mount each session and confirm each only recalls its own entry. + const a2 = setup('', 0, 'sess-a'); + a2.history.recallOlder(); + expect(a2.text.value).toBe('from-a'); + a2.history.recallOlder(); // no older entry — must stay + expect(a2.text.value).toBe('from-a'); + + const b2 = setup('', 0, 'sess-b'); + b2.history.recallOlder(); + expect(b2.text.value).toBe('from-b'); + }); + + it('trims to the newest 100 entries, dropping the oldest', () => { const { text, history } = setup(); - for (let i = 0; i < 205; i++) history.push(`m${i}`); + for (let i = 0; i < 105; 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(); + for (let i = 0; i < 100; i++) history.recallOlder(); expect(text.value).toBe('m5'); history.recallOlder(); // already at the oldest kept entry — must not move expect(text.value).toBe('m5'); @@ -216,4 +240,26 @@ describe('useInputHistory — persistence', () => { const { history } = setup(); expect(history.hasHistory()).toBe(false); }); + + it('migrates a legacy global array into the current session once', () => { + globalThis.localStorage.setItem(STORAGE_KEYS.inputHistory, JSON.stringify(['old1', 'old2'])); + const { text, history } = setup('', 0, 'sess-x'); + history.recallOlder(); // -> old2 + expect(text.value).toBe('old2'); + history.recallOlder(); // -> old1 + expect(text.value).toBe('old1'); + // Persisted in the new map format under the current session. + const stored = JSON.parse(globalThis.localStorage.getItem(STORAGE_KEYS.inputHistory)!); + expect(stored).toEqual({ 'sess-x': ['old1', 'old2'] }); + }); + + it('leaves the legacy array untouched when mounted without a session', () => { + globalThis.localStorage.setItem(STORAGE_KEYS.inputHistory, JSON.stringify(['old1'])); + const { history } = setup('', 0, null); + expect(history.hasHistory()).toBe(false); + // A later docked mount (with a session id) can still migrate it. + const { text, history: docked } = setup('', 0, 'sess-y'); + docked.recallOlder(); + expect(text.value).toBe('old1'); + }); }); From e4d4e07907647b63ebf524a5d01e93b985644aa6 Mon Sep 17 00:00:00 2001 From: qer Date: Sun, 28 Jun 2026 22:03:20 +0800 Subject: [PATCH 3/8] fix(web): suppress side panel open animation on session switch --- apps/kimi-web/src/App.vue | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 6b22e1309..4cdb98713 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -1,6 +1,6 @@