From 3b8c92ebdc64535f05650c18903b31f330ea5cad Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 23:12:29 +0800 Subject: [PATCH 1/7] feat(web): stabilize and drag-reorder workspaces in the sidebar --- .changeset/web-draggable-workspace-order.md | 5 + apps/kimi-web/src/App.vue | 1 + apps/kimi-web/src/components/Sidebar.vue | 105 +++++++++++++----- .../src/components/WorkspaceGroup.vue | 23 +++- .../src/composables/useKimiWebClient.ts | 64 +++++++++-- apps/kimi-web/src/lib/storage.ts | 18 +++ apps/kimi-web/src/lib/workspaceOrder.ts | 50 +++++++++ apps/kimi-web/test/storage-logic.test.ts | 26 +++++ apps/kimi-web/test/workspace-order.test.ts | 69 ++++++++++++ 9 files changed, 326 insertions(+), 35 deletions(-) create mode 100644 .changeset/web-draggable-workspace-order.md create mode 100644 apps/kimi-web/src/lib/workspaceOrder.ts create mode 100644 apps/kimi-web/test/workspace-order.test.ts diff --git a/.changeset/web-draggable-workspace-order.md b/.changeset/web-draggable-workspace-order.md new file mode 100644 index 0000000000..811fdf675f --- /dev/null +++ b/.changeset/web-draggable-workspace-order.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep the web sidebar's workspace order stable and let workspaces be reordered by drag-and-drop, persisted locally instead of following recent activity. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 0fa5d7b0c9..176910afaf 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -561,6 +561,7 @@ function openPr(url: string): void { @fork="(id) => client.forkSession(id)" @rename-workspace="(id, name) => client.renameWorkspace(id, name)" @delete-workspace="(id) => client.deleteWorkspace(id)" + @reorder-workspaces="client.reorderWorkspaces($event)" @select-workspaces="handleSelectWorkspaces" @open-settings="showSettings = true" @collapse="toggleSidebarCollapse" diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index 65619c682d..1b166c8196 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n'; import { serverEndpointLabel } from '../api/config'; import { copyTextToClipboard } from '../lib/clipboard'; import { loadCollapsedWorkspaces, saveCollapsedWorkspaces } from '../lib/storage'; +import { moveInOrder } from '../lib/workspaceOrder'; import type { Session, WorkspaceGroup as WorkspaceGroupType, WorkspaceView } from '../types'; import SessionRow from './SessionRow.vue'; import WorkspaceGroup from './WorkspaceGroup.vue'; @@ -56,6 +57,7 @@ const emit = defineEmits<{ fork: [id: string]; renameWorkspace: [id: string, name: string]; deleteWorkspace: [id: string]; + reorderWorkspaces: [ids: string[]]; openSettings: []; collapse: []; }>(); @@ -111,6 +113,41 @@ function toggleCollapse(id: string): void { saveCollapsedWorkspaces(next); } +// --------------------------------------------------------------------------- +// Workspace drag-to-reorder +// --------------------------------------------------------------------------- +// The header of each group is the drag handle (see WorkspaceGroup). We track +// which group is being dragged and which one is the current drop target (for a +// visual insertion marker), then on drop we emit the new id order upward — the +// parent persists it and the computed `groups` re-sorts. +const draggingWsId = ref(null); +const dragOverWsId = ref(null); + +function onWsDragstart(id: string): void { + draggingWsId.value = id; +} + +function onWsDragend(): void { + draggingWsId.value = null; + dragOverWsId.value = null; +} + +function onGroupDragOver(event: DragEvent, targetId: string): void { + if (draggingWsId.value === null || draggingWsId.value === targetId) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; + dragOverWsId.value = targetId; +} + +function onGroupDrop(targetId: string): void { + const fromId = draggingWsId.value; + dragOverWsId.value = null; + draggingWsId.value = null; + if (!fromId || fromId === targetId) return; + const next = moveInOrder(props.groups.map((g) => g.workspace.id), fromId, targetId); + emit('reorderWorkspaces', next); +} + // --------------------------------------------------------------------------- // Session list truncation per workspace // --------------------------------------------------------------------------- @@ -529,35 +566,45 @@ function blinkOnce(): void { @@ -839,6 +886,10 @@ function blinkOnce(): void { } .sessions::-webkit-scrollbar-thumb:hover { background: var(--bd); } +/* Workspace drag-to-reorder: a line at the top of the group under the cursor + marks where the dragged workspace will land. Inset shadow avoids layout shift. */ +.ws-drop-target.drop-before { box-shadow: inset 0 2px 0 var(--blue); } + .empty { padding: 24px 12px; text-align: center; diff --git a/apps/kimi-web/src/components/WorkspaceGroup.vue b/apps/kimi-web/src/components/WorkspaceGroup.vue index 816b621500..4d026c042a 100644 --- a/apps/kimi-web/src/components/WorkspaceGroup.vue +++ b/apps/kimi-web/src/components/WorkspaceGroup.vue @@ -23,6 +23,8 @@ const props = defineProps<{ pendingBySession: Record; unreadBySession: Record; wsMenuOpenId: string | null; + /** True while this group is the active drag source (drag-to-reorder). */ + dragging: boolean; isCollapsed: (id: string) => boolean; isExpanded: (id: string) => boolean; visibleSessions: (sessions: Session[], expanded: boolean, activeId?: string) => Session[]; @@ -41,6 +43,8 @@ const emit = defineEmits<{ confirmRename: []; cancelRename: []; updateRenameValue: [value: string]; + wsDragstart: [workspaceId: string]; + wsDragend: []; }>(); // v-model bridge: Sidebar owns renameValue (confirmRenameWorkspace reads it), @@ -57,15 +61,28 @@ const renameValueModel = computed({ function setRenameInputRef(el: Element | ComponentPublicInstance | null): void { props.renameInputRef.value = el instanceof HTMLInputElement ? el : null; } + +// Drag-to-reorder: the group header is the drag handle. We stash the workspace +// id on the dataTransfer (so drop targets elsewhere could read it) and tell the +// sidebar which group is being dragged so it can compute the new order on drop. +function onHeaderDragStart(event: DragEvent): void { + if (!event.dataTransfer) return; + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', props.group.workspace.id); + emit('wsDragstart', props.group.workspace.id); +}