diff --git a/.changeset/web-draggable-workspace-order.md b/.changeset/web-draggable-workspace-order.md new file mode 100644 index 000000000..24fd88e13 --- /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; sessions now also float to the top of their group as soon as a new message arrives. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 0fa5d7b0c..176910afa 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/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 6c50be965..44f43e709 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -350,6 +350,14 @@ export function reduceAppEvent( // ------------------------------------------------------------------------- case 'messageCreated': { const sid = event.message.sessionId; + // A new message is activity on the session: bump its recency so it floats + // to the top of its workspace group in the sidebar immediately. The daemon + // does not always broadcast a fresh `session.updated` for message activity, + // so we rely on the message's own timestamp (and never move it backwards). + const createdAt = event.message.createdAt; + next.sessions = next.sessions.map((s) => + s.id === sid && createdAt > s.updatedAt ? { ...s, updatedAt: createdAt } : s, + ); const msgs = next.messagesBySession[sid] ?? []; const exists = msgs.some((m) => m.id === event.message.id); if (!exists) { diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index 65619c682..b3ec0f0b4 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, type DropPosition } 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,54 @@ 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 where the insertion marker sits (before or +// after the group under the pointer), then on drop we emit the new id order +// upward — the parent persists it and the computed `groups` re-sorts. Using the +// pointer's position within the target (top half = before, bottom half = after) +// is what lets a workspace be dropped at the very bottom of the list. +const draggingWsId = ref(null); +const dragOver = ref<{ id: string; position: DropPosition } | null>(null); + +function onWsDragstart(id: string): void { + draggingWsId.value = id; +} + +function onWsDragend(): void { + draggingWsId.value = null; + dragOver.value = null; +} + +function dropPosition(event: DragEvent): DropPosition { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + return event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; +} + +function onGroupDragOver(event: DragEvent, targetId: string): void { + if (draggingWsId.value === null || draggingWsId.value === targetId) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; + dragOver.value = { id: targetId, position: dropPosition(event) }; +} + +function onGroupDrop(targetId: string): void { + const fromId = draggingWsId.value; + const position = dragOver.value?.id === targetId ? dragOver.value.position : 'before'; + dragOver.value = null; + draggingWsId.value = null; + if (!fromId || fromId === targetId) return; + const next = moveInOrder( + props.groups.map((g) => g.workspace.id), + fromId, + targetId, + position, + ); + emit('reorderWorkspaces', next); +} + // --------------------------------------------------------------------------- // Session list truncation per workspace // --------------------------------------------------------------------------- @@ -529,35 +579,48 @@ function blinkOnce(): void { @@ -839,6 +902,12 @@ function blinkOnce(): void { } .sessions::-webkit-scrollbar-thumb:hover { background: var(--bd); } +/* Workspace drag-to-reorder: a line at the top (drop-before) or bottom + (drop-after) of the group under the cursor marks where the dragged workspace + will land. Inset shadows avoid layout shift. */ +.ws-drop-target.drop-before { box-shadow: inset 0 2px 0 var(--blue); } +.ws-drop-target.drop-after { 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 816b62150..4d026c042 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); +}