Skip to content
5 changes: 5 additions & 0 deletions .changeset/web-preserve-scroll-position.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Restore each session's scroll position when switching back to it in the web UI.
5 changes: 5 additions & 0 deletions .changeset/web-preserve-side-panel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep the open side panel when switching between sessions in the web UI.
5 changes: 5 additions & 0 deletions .changeset/web-scope-composer-attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep unsent composer attachments scoped to their session in the web UI, so switching sessions no longer leaks them into another session's next message.
5 changes: 5 additions & 0 deletions .changeset/web-session-input-history.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 11 additions & 2 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!-- apps/kimi-web/src/App.vue -->
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, provide, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Sidebar from './components/Sidebar.vue';
import ResizeHandle from './components/ResizeHandle.vue';
Expand Down Expand Up @@ -136,6 +136,15 @@ function onGlobalKeydown(e: KeyboardEvent): void {
// ---------------------------------------------------------------------------
const detailTarget = ref<DetailTarget | null>(null);

// True for one frame while the active session changes: suppresses the right
// panel's width transition so a restored panel snaps to its width instead of
// animating open from zero.
const panelSwitching = ref(false);
watch(client.activeSessionId, () => {
panelSwitching.value = true;
void nextTick(() => { panelSwitching.value = false; });
});

const {
previewTarget,
previewFile,
Expand Down Expand Up @@ -735,7 +744,7 @@ function openPr(url: string): void {
<aside
v-if="!isMobile || sidePanelVisible"
class="global-preview"
:class="{ open: sidePanelVisible, mobile: isMobile, 'no-anim': panelDragging }"
:class="{ open: sidePanelVisible, mobile: isMobile, 'no-anim': panelDragging || panelSwitching }"
role="complementary"
:aria-label="t('layout.detailPanelAria')"
:aria-hidden="!sidePanelVisible"
Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-web/src/components/chat/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -232,7 +232,7 @@ const {
handleDragLeave,
handleDrop,
clearAfterSubmit,
} = useAttachmentUpload({ uploadImage: () => props.uploadImage });
} = useAttachmentUpload({ uploadImage: () => props.uploadImage, sessionId: () => props.sessionId });

// Silence noUnusedLocals: fileInputRef is used as a template ref (ref="fileInputRef").
void fileInputRef;
Expand Down
31 changes: 27 additions & 4 deletions apps/kimi-web/src/components/chat/ConversationPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -659,13 +659,36 @@ watch(
},
);

// Per-session scroll state: switching back to a session restores both the scroll
// position and whether the user was following the bottom, instead of always
// jumping to the bottom (which replayed the conversation when the session was
// already there) or getting yanked to the bottom by a new message after
// restoring a scrolled-up position.
const scrollStateBySession = new Map<string, { top: number; following: boolean }>();

watch(
() => props.fileReloadKey,
async () => {
following.value = true;
lastScrollTop = 0;
async (newKey, oldKey) => {
const el = panesRef.value;
if (oldKey && el) {
scrollStateBySession.set(String(oldKey), { top: el.scrollTop, following: following.value });
}
await nextTick();
scheduleStableFollow();
const el2 = panesRef.value;
const saved = newKey ? scrollStateBySession.get(String(newKey)) : undefined;
if (saved && el2) {
following.value = saved.following;
el2.scrollTop = saved.top;
lastScrollTop = saved.top;
if (saved.following) {
scheduleStableFollow();
}
} else {
following.value = true;
lastScrollTop = 0;
scrollToBottom(false);
scheduleStableFollow();
}
updateTocViewport();
},
);
Expand Down
81 changes: 54 additions & 27 deletions apps/kimi-web/src/composables/useAttachmentUpload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
// apps/kimi-web/src/composables/useAttachmentUpload.ts
import { onMounted, onUnmounted, ref } from 'vue';
// Image/video attachment handling for the composer: file picker, paste, drag &
// drop, the upload machinery, the chip strip, and the preview lightbox.
//
// Pending attachments are scoped per session (keyed by session id) so switching
// sessions can't leak one session's unsent attachments into another session's
// next submit. The composer keeps `handleSubmit`/`handleSteer` (which read the
// attachments to build the payload) and the `hasUpload` toolbar flag; this
// composable owns the attachment state, all the file-input UI handlers, and the
// paste listener + object-URL cleanup lifecycle.

import { computed, onMounted, onUnmounted, ref, watch } from 'vue';

export interface Attachment {
/** Unique local id (used as :key) */
Expand Down Expand Up @@ -27,21 +37,15 @@ export interface AttachmentUploadDeps {
/** Upload a blob; resolves to the daemon file id, or null on failure.
Getter so a prop change is picked up. Undefined disables attaching. */
uploadImage: () => UploadImage | undefined;
/** Active session id — scopes pending attachments (getter for reactivity). */
sessionId: () => string | undefined;
}

/**
* Image/video attachment handling for the composer: file picker, paste, drag &
* drop, the upload machinery, the chip strip, and the preview lightbox.
*
* The composer keeps `handleSubmit`/`handleSteer` (which read the attachments to
* build the payload) and the `hasUpload` toolbar flag; this composable owns the
* attachment state, all the file-input UI handlers, and the paste listener +
* object-URL cleanup lifecycle.
*/
export function useAttachmentUpload(deps: AttachmentUploadDeps) {
const { uploadImage } = deps;
const { uploadImage, sessionId } = deps;

const attachments = ref<Attachment[]>([]);
const attachmentsBySession = ref<Record<string, Attachment[]>>({});
const attachments = computed(() => attachmentsBySession.value[sessionId() ?? ''] ?? []);
const previewAttachment = ref<Attachment | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
const isDragOver = ref(false);
Expand All @@ -51,6 +55,10 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
return `att_${++localIdCounter}`;
}

function setForSession(sid: string, next: Attachment[]): void {
attachmentsBySession.value = { ...attachmentsBySession.value, [sid]: next };
}

function revokeAttachment(att: Attachment): void {
try { URL.revokeObjectURL(att.previewUrl); } catch { /* ignore */ }
}
Expand All @@ -64,6 +72,9 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
async function addFiles(files: File[]): Promise<void> {
const upload = uploadImage();
if (!upload) return;
// Capture the session at upload time; async completion must update the same
// session even if the user has since switched away.
const sid = sessionId() ?? '';
const media = files
.map((file) => ({ file, kind: mediaKind(file.type) }))
.filter((m): m is { file: File; kind: 'image' | 'video' } => m.kind !== null);
Expand All @@ -73,28 +84,36 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
const localId = nextLocalId();
const previewUrl = URL.createObjectURL(file);
const att: Attachment = { localId, name: file.name, kind, previewUrl, uploading: true };
attachments.value = [...attachments.value, att];
setForSession(sid, [...(attachmentsBySession.value[sid] ?? []), att]);

// Upload in background; update the attachment when done.
upload(file, file.name).then((result) => {
attachments.value = attachments.value.map((a) =>
a.localId === localId
? { ...a, uploading: false, fileId: result?.fileId, error: result === null }
: a,
const current = attachmentsBySession.value[sid] ?? [];
setForSession(
sid,
current.map((a) =>
a.localId === localId
? { ...a, uploading: false, fileId: result?.fileId, error: result === null }
: a,
),
);
}).catch(() => {
attachments.value = attachments.value.map((a) =>
a.localId === localId ? { ...a, uploading: false, error: true } : a,
const current = attachmentsBySession.value[sid] ?? [];
setForSession(
sid,
current.map((a) => (a.localId === localId ? { ...a, uploading: false, error: true } : a)),
);
});
}
}

function removeAttachment(localId: string): void {
const att = attachments.value.find((a) => a.localId === localId);
const sid = sessionId() ?? '';
const current = attachmentsBySession.value[sid] ?? [];
const att = current.find((a) => a.localId === localId);
if (previewAttachment.value?.localId === localId) previewAttachment.value = null;
if (att) revokeAttachment(att);
attachments.value = attachments.value.filter((a) => a.localId !== localId);
setForSession(sid, current.filter((a) => a.localId !== localId));
}

function openAttachmentPreview(att: Attachment): void {
Expand Down Expand Up @@ -179,23 +198,31 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
void addFiles(files);
}

/** Revoke every object URL and drop all attachments (called after submit/steer). */
/** Revoke every object URL and drop all attachments for the current session
(called after submit/steer). */
function clearAfterSubmit(): void {
for (const att of attachments.value) {
const sid = sessionId() ?? '';
for (const att of attachmentsBySession.value[sid] ?? []) {
revokeAttachment(att);
}
attachments.value = [];
setForSession(sid, []);
}

// Close the preview lightbox when switching sessions — it may reference an
// attachment that belongs to the previous session.
watch(sessionId, () => {
previewAttachment.value = null;
});

onMounted(() => {
document.addEventListener('paste', handleDocumentPaste);
});

// Revoke all object URLs and remove the global listener on unmount.
// Revoke all object URLs (every session) and remove the global listener on unmount.
onUnmounted(() => {
document.removeEventListener('paste', handleDocumentPaste);
for (const att of attachments.value) {
revokeAttachment(att);
for (const atts of Object.values(attachmentsBySession.value)) {
for (const att of atts) revokeAttachment(att);
}
previewAttachment.value = null;
});
Expand Down
78 changes: 77 additions & 1 deletion apps/kimi-web/src/composables/useDetailPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, PanelSnapshot>>({});

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; }
Expand All @@ -273,14 +337,26 @@ 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();
closeAgentPanel();
closeToolDiff();
closeDiffDetail();
hideSideChatPanel();
// Restore the entering session's panel, if it had one.
if (newId) {
restoreSnapshot(snapshotBySession.value[newId]);
}
});

return {
Expand Down
Loading
Loading