Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-draggable-workspace-order.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions apps/kimi-web/src/api/daemon/eventReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
123 changes: 96 additions & 27 deletions apps/kimi-web/src/components/Sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -56,6 +57,7 @@ const emit = defineEmits<{
fork: [id: string];
renameWorkspace: [id: string, name: string];
deleteWorkspace: [id: string];
reorderWorkspaces: [ids: string[]];
openSettings: [];
collapse: [];
}>();
Expand Down Expand Up @@ -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<string | null>(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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -529,35 +579,48 @@ function blinkOnce(): void {
</div>

<template v-else>
<WorkspaceGroup
<div
v-for="g in groups"
:key="g.workspace.id"
:group="g"
:active-workspace-id="activeWorkspaceId"
:active-id="activeId"
:selected-ids="selectedIds"
:renaming-id="renamingId"
:rename-value="renameValue"
:rename-input-ref="getRenameInputRef()"
:pending-by-session="pendingBySession"
:unread-by-session="unreadBySession"
:ws-menu-open-id="wsMenuOpenId"
:is-collapsed="isCollapsed"
:is-expanded="isExpanded"
:visible-sessions="visibleSessions"
@group-click="handleGhClick"
@group-contextmenu="openGhMenu"
@toggle-ws-menu="toggleWsMenu"
@create-in-workspace="(id) => emit('createInWorkspace', id)"
@select-session="onSelectSession"
@rename-session="(id, title) => emit('rename', id, title)"
@archive-session="(id) => emit('archive', id)"
@fork-session="(id) => emit('fork', id)"
@toggle-expand="toggleExpand"
@confirm-rename="confirmRenameWorkspace"
@cancel-rename="cancelRenameWorkspace"
@update-rename-value="onUpdateRenameValue"
/>
class="ws-drop-target"
:class="{
'drop-before': dragOver?.id === g.workspace.id && dragOver.position === 'before',
'drop-after': dragOver?.id === g.workspace.id && dragOver.position === 'after',
}"
@dragover="onGroupDragOver($event, g.workspace.id)"
@drop="onGroupDrop(g.workspace.id)"
Comment on lines +590 to +591

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add an end-of-list drop target

With the current wiring every valid drop is tied to an existing workspace id and moveInOrder always inserts the dragged workspace before that target. In a list like [A, B, C], dragging A onto C yields [B, A, C], so there is no single drop position that can produce [B, C, A]; the new drag-reorder UI cannot move a workspace directly to the bottom. Add an after-last/bottom drop zone or distinguish before/after based on pointer position.

Useful? React with 👍 / 👎.

>
<WorkspaceGroup
:group="g"
:active-workspace-id="activeWorkspaceId"
:active-id="activeId"
:selected-ids="selectedIds"
:renaming-id="renamingId"
:rename-value="renameValue"
:rename-input-ref="getRenameInputRef()"
:pending-by-session="pendingBySession"
:unread-by-session="unreadBySession"
:ws-menu-open-id="wsMenuOpenId"
:dragging="draggingWsId === g.workspace.id"
:is-collapsed="isCollapsed"
:is-expanded="isExpanded"
:visible-sessions="visibleSessions"
@group-click="handleGhClick"
@group-contextmenu="openGhMenu"
@toggle-ws-menu="toggleWsMenu"
@create-in-workspace="(id) => emit('createInWorkspace', id)"
@select-session="onSelectSession"
@rename-session="(id, title) => emit('rename', id, title)"
@archive-session="(id) => emit('archive', id)"
@fork-session="(id) => emit('fork', id)"
@toggle-expand="toggleExpand"
@confirm-rename="confirmRenameWorkspace"
@cancel-rename="cancelRenameWorkspace"
@update-rename-value="onUpdateRenameValue"
@ws-dragstart="onWsDragstart"
@ws-dragend="onWsDragend"
/>
</div>
</template>
</div>
</div>
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 22 additions & 1 deletion apps/kimi-web/src/components/WorkspaceGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const props = defineProps<{
pendingBySession: Record<string, { approvals: number; questions: number }>;
unreadBySession: Record<string, boolean>;
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[];
Expand All @@ -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),
Expand All @@ -57,15 +61,28 @@ const renameValueModel = computed<string>({
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);
}
</script>

<template>
<div class="group">
<div class="group" :class="{ dragging }">
<div
class="gh"
:class="{ on: group.workspace.id === activeWorkspaceId, sel: selectedIds.has(group.workspace.id) }"
draggable="true"
@click.stop="emit('groupClick', group.workspace.id, $event)"
@contextmenu="emit('groupContextmenu', group.workspace, $event)"
@dragstart="onHeaderDragStart"
@dragend="emit('wsDragend')"
>
<div class="gh-top">
<!-- Folder icon -->
Expand Down Expand Up @@ -168,6 +185,7 @@ function setRenameInputRef(el: Element | ComponentPublicInstance | null): void {
/* Workspace group. The --sb-* custom properties are inherited from .side in
Sidebar.vue, so they don't need to be redeclared here. */
.group { padding-bottom: 6px; }
.group.dragging { opacity: 0.45; }
.gh {
display: flex;
flex-direction: column;
Expand All @@ -176,7 +194,10 @@ function setRenameInputRef(el: Element | ComponentPublicInstance | null): void {
font-size: max(9px, calc(var(--ui-font-size) - 3.5px));
user-select: none;
position: relative;
/* The header doubles as the drag handle for reordering. */
cursor: grab;
}
.gh:active { cursor: grabbing; }
.gh-top {
display: flex;
align-items: center;
Expand Down
15 changes: 12 additions & 3 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ import { safeRemove, STORAGE_KEYS } from '../../lib/storage';
import { parseDiff } from '../../lib/parseDiff';
import { readSessionIdFromLocation, sessionUrl } from '../../lib/sessionRoute';
import type { SessionUrlMode } from '../../lib/sessionRoute';
import type { ActivityState, ConversationStatus, DiffViewLine, PermissionMode } from '../../types';
import type {
ActivityState,
ConversationStatus,
DiffViewLine,
PermissionMode,
WorkspaceView,
} from '../../types';
import type { ExtendedState, PromptAttachment } from '../useKimiWebClient';
import type { UseModelProviderState } from './useModelProviderState';
import type { UseSideChat } from './useSideChat';
Expand Down Expand Up @@ -79,6 +85,8 @@ export interface UseWorkspaceStateDeps {
refreshSessionStatus: (sessionId: string) => Promise<void>;
persistSessionProfile: (patch: PersistSessionProfilePatch) => void;
mergedWorkspaces: ComputedRef<AppWorkspace[]>;
/** Sidebar-facing workspaces in the user's (dragged) display order. */
workspacesView: ComputedRef<WorkspaceView[]>;
status: ComputedRef<ConversationStatus>;
workspaceIdForSession: (s: { workspaceId?: string; cwd: string }) => string;
savePermissionToStorage: (mode: PermissionMode) => void;
Expand Down Expand Up @@ -121,6 +129,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
refreshSessionStatus,
persistSessionProfile,
mergedWorkspaces,
workspacesView,
status,
workspaceIdForSession,
savePermissionToStorage,
Expand Down Expand Up @@ -446,7 +455,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
const removingActiveWorkspace =
rawState.activeWorkspaceId === event.workspaceId || rawState.activeWorkspaceId === root;
if (removingActiveWorkspace) {
const nextWorkspace = mergedWorkspaces.value[0]?.id ?? null;
const nextWorkspace = workspacesView.value[0]?.id ?? null;
rawState.activeWorkspaceId = nextWorkspace;
if (nextWorkspace) saveActiveWorkspaceToStorage(nextWorkspace);
else {
Expand Down Expand Up @@ -1309,7 +1318,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
}
rawState.workspaces = rawState.workspaces.filter((w) => w.id !== id && w.root !== root);
if (removingActiveWorkspace || activeSessionInRemovedWorkspace) {
const nextWorkspace = mergedWorkspaces.value[0]?.id ?? null;
const nextWorkspace = workspacesView.value[0]?.id ?? null;
rawState.activeWorkspaceId = nextWorkspace;
if (nextWorkspace) saveActiveWorkspaceToStorage(nextWorkspace);
else {
Expand Down
Loading
Loading