mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat(web): stabilize and drag-reorder workspaces in the sidebar (#1047)
* feat(web): stabilize and drag-reorder workspaces in the sidebar * fix(web): preserve dragged workspace order after refresh * fix(web): float session to top of its group on new message * fix(web): align workspace drop order with insertion marker * fix(web): allow dropping a workspace after the last item * fix(web): use reordered workspaces for active fallback * fix(web): honor drag order for next-workspace fallback on removal
This commit is contained in:
parent
b93e9365b6
commit
98d3e5b71d
13 changed files with 504 additions and 39 deletions
5
.changeset/web-draggable-workspace-order.md
Normal file
5
.changeset/web-draggable-workspace-order.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -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)"
|
||||
>
|
||||
<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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
@ -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<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 -->
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -121,6 +129,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
|||
refreshSessionStatus,
|
||||
persistSessionProfile,
|
||||
mergedWorkspaces,
|
||||
workspacesView,
|
||||
status,
|
||||
workspaceIdForSession,
|
||||
savePermissionToStorage,
|
||||
|
|
@ -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 {
|
||||
|
|
@ -1297,7 +1306,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 {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,21 @@
|
|||
// Vue state composable — the only place that imports both src/api/* and src/types.ts.
|
||||
// Components consume computed view props and call actions; they never touch the API or reducer.
|
||||
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { i18n } from '../i18n';
|
||||
import { getKimiWebApi } from '../api';
|
||||
import { isDaemonApiError, isDaemonNetworkError } from '../api/errors';
|
||||
import { loadUnread, safeGetString, safeRemove, safeSetString, saveUnread, STORAGE_KEYS } from '../lib/storage';
|
||||
import { reconcileWorkspaceOrder, sortByWorkspaceOrder } from '../lib/workspaceOrder';
|
||||
import {
|
||||
loadUnread,
|
||||
loadWorkspaceOrder,
|
||||
safeGetString,
|
||||
safeRemove,
|
||||
safeSetString,
|
||||
saveUnread,
|
||||
saveWorkspaceOrder,
|
||||
STORAGE_KEYS,
|
||||
} from '../lib/storage';
|
||||
import { useAppearance } from './client/useAppearance';
|
||||
import { useNotification } from './client/useNotification';
|
||||
import { useTaskPoller } from './client/useTaskPoller';
|
||||
|
|
@ -1666,22 +1676,59 @@ const mergedWorkspaces = computed<AppWorkspace[]>(() => {
|
|||
return result;
|
||||
});
|
||||
|
||||
/** Sidebar-facing workspace list. */
|
||||
const workspacesView = computed<WorkspaceView[]>(() =>
|
||||
mergedWorkspaces.value.map((w) => ({
|
||||
/**
|
||||
* User-defined display order of workspace ids, persisted to localStorage. The
|
||||
* sidebar stops following the daemon's recency-based order: once a workspace is
|
||||
* known, its position is fixed until the user drags it elsewhere.
|
||||
*/
|
||||
const workspaceOrder = ref<string[]>(loadWorkspaceOrder());
|
||||
|
||||
// Reconcile the persisted order with the set of currently-known workspaces:
|
||||
// drop ids that no longer exist, and prepend newly-seen ids (newest first,
|
||||
// matching "createdAt desc" — the closest signal we have without a real
|
||||
// workspace creation timestamp). Watched on the id *set* (joined) so a pure
|
||||
// daemon reorder of the same workspaces does not rewrite the user's order, and
|
||||
// a drag reorder (which also writes `workspaceOrder` but keeps the same id set)
|
||||
// does not re-trigger it.
|
||||
//
|
||||
// The watch also tracks `loading` and bails out while a load is in progress.
|
||||
// During `load()`, sessions (and thus derived workspaces) are set *before* the
|
||||
// real workspaces arrive, so a real workspace with no sessions is momentarily
|
||||
// absent from `mergedWorkspaces`. Without the loading guard the reconciler would
|
||||
// drop it as "deleted" and then, when it appears a tick later, re-add it at the
|
||||
// top — undoing the user's drag on refresh. Waiting until the load settles
|
||||
// means we always reconcile against the complete set.
|
||||
watch(
|
||||
() => [mergedWorkspaces.value.map((w) => w.id).join('\0'), rawState.loading] as const,
|
||||
([idsKey, loading]) => {
|
||||
if (loading) return;
|
||||
const current = idsKey ? idsKey.split('\0') : [];
|
||||
const next = reconcileWorkspaceOrder(current, workspaceOrder.value);
|
||||
if (next === null) return;
|
||||
workspaceOrder.value = next;
|
||||
saveWorkspaceOrder(next);
|
||||
},
|
||||
);
|
||||
|
||||
/** Sidebar-facing workspace list, ordered by the persisted/dragged order. */
|
||||
const workspacesView = computed<WorkspaceView[]>(() => {
|
||||
const views = mergedWorkspaces.value.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
root: w.root,
|
||||
shortPath: shortenHome(w.root, rawState.fsHome),
|
||||
branch: w.branch,
|
||||
sessionCount: w.sessionCount,
|
||||
})),
|
||||
);
|
||||
}));
|
||||
return sortByWorkspaceOrder(views, workspaceOrder.value);
|
||||
});
|
||||
|
||||
/** The active workspace id, falling back to the first available workspace. */
|
||||
const activeWorkspaceId = computed<string | null>(() => {
|
||||
const id = rawState.activeWorkspaceId;
|
||||
const list = mergedWorkspaces.value;
|
||||
// Use the reordered list (not the raw daemon order) so the default/fallback
|
||||
// workspace matches the first group the user actually sees in the sidebar.
|
||||
const list = workspacesView.value;
|
||||
if (id && list.some((w) => w.id === id)) return id;
|
||||
return list[0]?.id ?? null;
|
||||
});
|
||||
|
|
@ -1742,6 +1789,16 @@ const workspaceGroups = computed<WorkspaceGroup[]>(() => {
|
|||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* Replace the workspace display order (e.g. after a drag reorder in the
|
||||
* sidebar) and persist it. The id set is unchanged, so the reconciliation
|
||||
* watcher above will not fire — only the sort in `workspacesView` reacts.
|
||||
*/
|
||||
function reorderWorkspaces(ids: string[]): void {
|
||||
workspaceOrder.value = ids;
|
||||
saveWorkspaceOrder(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session pending-attention count = pending approvals + pending questions.
|
||||
* For the active session this is live (driven by WS events). Other sessions
|
||||
|
|
@ -1854,6 +1911,7 @@ const workspaceState = useWorkspaceState(rawState, {
|
|||
refreshSessionStatus,
|
||||
persistSessionProfile,
|
||||
mergedWorkspaces,
|
||||
workspacesView,
|
||||
status,
|
||||
workspaceIdForSession,
|
||||
savePermissionToStorage,
|
||||
|
|
@ -2077,6 +2135,7 @@ export function useKimiWebClient() {
|
|||
renameSession: workspaceState.renameSession,
|
||||
renameWorkspace: workspaceState.renameWorkspace,
|
||||
deleteWorkspace: workspaceState.deleteWorkspace,
|
||||
reorderWorkspaces,
|
||||
archiveSession: workspaceState.archiveSession,
|
||||
compact: workspaceState.compact,
|
||||
forkSession: workspaceState.forkSession,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const STORAGE_KEYS = {
|
|||
colorScheme: 'kimi-web.color-scheme',
|
||||
hiddenWorkspaces: 'kimi-web.hidden-workspaces',
|
||||
collapsedWorkspaces: 'kimi-web.collapsed-workspaces',
|
||||
workspaceOrder: 'kimi-web.workspace-order',
|
||||
betaToc: 'kimi-web.beta-toc',
|
||||
notifyOnComplete: 'kimi-web.notify-on-complete',
|
||||
inputHistory: 'kimi-web.input-history',
|
||||
|
|
@ -138,3 +139,20 @@ export function loadCollapsedWorkspaces(): string[] {
|
|||
export function saveCollapsedWorkspaces(ids: Iterable<string>): void {
|
||||
safeSetJson(STORAGE_KEYS.collapsedWorkspaces, Array.from(ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display order of workspace ids in the sidebar. Persisted as a JSON array so
|
||||
* the user can drag workspaces into a custom order that survives a page
|
||||
* refresh. There is no server-side source of truth for this UI-only ordering;
|
||||
* workspaces absent from the list are treated as "not yet placed" and inserted
|
||||
* by the caller (newest first).
|
||||
*/
|
||||
export function loadWorkspaceOrder(): string[] {
|
||||
const parsed = safeGetJson<unknown>(STORAGE_KEYS.workspaceOrder);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((id): id is string => typeof id === 'string');
|
||||
}
|
||||
|
||||
export function saveWorkspaceOrder(ids: Iterable<string>): void {
|
||||
safeSetJson(STORAGE_KEYS.workspaceOrder, Array.from(ids));
|
||||
}
|
||||
|
|
|
|||
63
apps/kimi-web/src/lib/workspaceOrder.ts
Normal file
63
apps/kimi-web/src/lib/workspaceOrder.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// apps/kimi-web/src/lib/workspaceOrder.ts
|
||||
// Pure helpers for the sidebar's user-defined workspace order. Kept separate
|
||||
// from the composable so the reconciliation and sort rules are unit-testable
|
||||
// without mounting Vue state.
|
||||
|
||||
/**
|
||||
* Merge the set of currently-known workspace ids into the persisted order.
|
||||
* - Ids that no longer exist are dropped.
|
||||
* - Newly-seen ids are prepended (newest first — the closest signal to a
|
||||
* creation time we have, since workspaces carry no createdAt timestamp).
|
||||
* - Returns `null` when nothing changed, so callers can skip a redundant write.
|
||||
* - Returns `null` for an empty `currentIds` so an initial not-yet-loaded state
|
||||
* never wipes the stored order.
|
||||
*/
|
||||
export function reconcileWorkspaceOrder(
|
||||
currentIds: string[],
|
||||
storedOrder: string[],
|
||||
): string[] | null {
|
||||
if (currentIds.length === 0) return null;
|
||||
const currentSet = new Set(currentIds);
|
||||
const kept = storedOrder.filter((id) => currentSet.has(id));
|
||||
const newIds = currentIds.filter((id) => !storedOrder.includes(id));
|
||||
if (newIds.length === 0 && kept.length === storedOrder.length) return null;
|
||||
return [...newIds, ...kept];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort items by their position in `order`. Items absent from `order` sort to
|
||||
* the front (a just-discovered workspace appears at the top immediately, before
|
||||
* the reconciliation watcher records it). The sort is stable, so items sharing
|
||||
* a position keep their relative order.
|
||||
*/
|
||||
export function sortByWorkspaceOrder<T extends { id: string }>(items: T[], order: string[]): T[] {
|
||||
const index = new Map(order.map((id, i) => [id, i]));
|
||||
return items.toSorted((a, b) => (index.get(a.id) ?? -1) - (index.get(b.id) ?? -1));
|
||||
}
|
||||
|
||||
export type DropPosition = 'before' | 'after';
|
||||
|
||||
/**
|
||||
* Move `fromId` so it lands immediately before or after `toId` — matching the
|
||||
* insertion marker shown in the sidebar (a line at the top of the target for
|
||||
* "before", at the bottom for "after"). Returns the original array unchanged
|
||||
* when either id is missing or they are the same. After the source is removed,
|
||||
* a downward move shifts the target left by one, so the target index is
|
||||
* rebased before applying the position.
|
||||
*/
|
||||
export function moveInOrder(
|
||||
order: string[],
|
||||
fromId: string,
|
||||
toId: string,
|
||||
position: DropPosition = 'before',
|
||||
): string[] {
|
||||
const fromIdx = order.indexOf(fromId);
|
||||
const toIdx = order.indexOf(toId);
|
||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return order;
|
||||
const next = [...order];
|
||||
next.splice(fromIdx, 1);
|
||||
const shiftedToIdx = fromIdx < toIdx ? toIdx - 1 : toIdx;
|
||||
const insertIdx = position === 'before' ? shiftedToIdx : shiftedToIdx + 1;
|
||||
next.splice(insertIdx, 0, fromId);
|
||||
return next;
|
||||
}
|
||||
83
apps/kimi-web/test/event-reducer.test.ts
Normal file
83
apps/kimi-web/test/event-reducer.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { createInitialState, reduceAppEvent } from '../src/api/daemon/eventReducer';
|
||||
import type { AppMessage, AppSession } from '../src/api/types';
|
||||
|
||||
function makeSession(id: string, updatedAt: string): AppSession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
status: 'idle',
|
||||
archived: false,
|
||||
cwd: '/workspace',
|
||||
model: 'kimi-code',
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
totalCostUsd: 0,
|
||||
contextTokens: 0,
|
||||
contextLimit: 0,
|
||||
turnCount: 0,
|
||||
},
|
||||
messageCount: 0,
|
||||
lastSeq: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(sessionId: string, createdAt: string): AppMessage {
|
||||
return {
|
||||
id: `msg_${createdAt}`,
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe('reduceAppEvent messageCreated', () => {
|
||||
it('bumps the session updatedAt so it floats to the top of the sidebar', () => {
|
||||
const state = {
|
||||
...createInitialState(),
|
||||
sessions: [makeSession('s-old', '2026-01-01T00:00:00.000Z')],
|
||||
};
|
||||
const next = reduceAppEvent(
|
||||
state,
|
||||
{ type: 'messageCreated', message: makeMessage('s-old', '2026-06-01T12:00:00.000Z') },
|
||||
{ sessionId: 's-old', seq: 1 },
|
||||
);
|
||||
expect(next.sessions[0]?.updatedAt).toBe('2026-06-01T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('does not move a session backwards when an older message arrives', () => {
|
||||
const state = {
|
||||
...createInitialState(),
|
||||
sessions: [makeSession('s-new', '2026-06-01T12:00:00.000Z')],
|
||||
};
|
||||
const next = reduceAppEvent(
|
||||
state,
|
||||
{ type: 'messageCreated', message: makeMessage('s-new', '2026-01-01T00:00:00.000Z') },
|
||||
{ sessionId: 's-new', seq: 1 },
|
||||
);
|
||||
expect(next.sessions[0]?.updatedAt).toBe('2026-06-01T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('leaves other sessions untouched', () => {
|
||||
const state = {
|
||||
...createInitialState(),
|
||||
sessions: [
|
||||
makeSession('s-a', '2026-01-01T00:00:00.000Z'),
|
||||
makeSession('s-b', '2026-01-01T00:00:00.000Z'),
|
||||
],
|
||||
};
|
||||
const next = reduceAppEvent(
|
||||
state,
|
||||
{ type: 'messageCreated', message: makeMessage('s-a', '2026-06-01T12:00:00.000Z') },
|
||||
{ sessionId: 's-a', seq: 1 },
|
||||
);
|
||||
expect(next.sessions.find((s) => s.id === 's-a')?.updatedAt).toBe('2026-06-01T12:00:00.000Z');
|
||||
expect(next.sessions.find((s) => s.id === 's-b')?.updatedAt).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
|
@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import {
|
||||
loadCollapsedWorkspaces,
|
||||
loadUnread,
|
||||
loadWorkspaceOrder,
|
||||
saveCollapsedWorkspaces,
|
||||
saveUnread,
|
||||
saveWorkspaceOrder,
|
||||
STORAGE_KEYS,
|
||||
draftStorageKey,
|
||||
safeGetJson,
|
||||
|
|
@ -195,3 +197,27 @@ describe('loadCollapsedWorkspaces / saveCollapsedWorkspaces', () => {
|
|||
expect(loadCollapsedWorkspaces()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadWorkspaceOrder / saveWorkspaceOrder', () => {
|
||||
it('returns an empty array when the key is missing', () => {
|
||||
expect(loadWorkspaceOrder()).toEqual([]);
|
||||
});
|
||||
|
||||
it('round-trips the ordered ids', () => {
|
||||
saveWorkspaceOrder(['ws-2', 'ws-1']);
|
||||
expect(loadWorkspaceOrder()).toEqual(['ws-2', 'ws-1']);
|
||||
});
|
||||
|
||||
it('accepts any iterable of ids', () => {
|
||||
saveWorkspaceOrder(new Set(['ws-3', 'ws-1']));
|
||||
expect(loadWorkspaceOrder()).toEqual(['ws-3', 'ws-1']);
|
||||
});
|
||||
|
||||
it('drops non-string entries and returns [] for malformed values', () => {
|
||||
safeSetString(STORAGE_KEYS.workspaceOrder, JSON.stringify(['ws-1', 2, null]));
|
||||
expect(loadWorkspaceOrder()).toEqual(['ws-1']);
|
||||
|
||||
safeSetString(STORAGE_KEYS.workspaceOrder, JSON.stringify({ ws: true }));
|
||||
expect(loadWorkspaceOrder()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
102
apps/kimi-web/test/workspace-order.test.ts
Normal file
102
apps/kimi-web/test/workspace-order.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
moveInOrder,
|
||||
reconcileWorkspaceOrder,
|
||||
sortByWorkspaceOrder,
|
||||
} from '../src/lib/workspaceOrder';
|
||||
|
||||
describe('reconcileWorkspaceOrder', () => {
|
||||
it('returns null for an empty current set so a not-yet-loaded state never wipes the order', () => {
|
||||
expect(reconcileWorkspaceOrder([], ['ws-1', 'ws-2'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the id set is unchanged (a daemon reorder must not rewrite the order)', () => {
|
||||
expect(reconcileWorkspaceOrder(['ws-2', 'ws-1'], ['ws-1', 'ws-2'])).toBeNull();
|
||||
});
|
||||
|
||||
it('prepends newly-seen ids (newest first)', () => {
|
||||
expect(reconcileWorkspaceOrder(['ws-3', 'ws-1', 'ws-2'], ['ws-1', 'ws-2'])).toEqual([
|
||||
'ws-3',
|
||||
'ws-1',
|
||||
'ws-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops ids that no longer exist', () => {
|
||||
expect(reconcileWorkspaceOrder(['ws-1'], ['ws-2', 'ws-1', 'ws-3'])).toEqual(['ws-1']);
|
||||
});
|
||||
|
||||
it('snapshots the initial order on first load', () => {
|
||||
expect(reconcileWorkspaceOrder(['ws-2', 'ws-1'], [])).toEqual(['ws-2', 'ws-1']);
|
||||
});
|
||||
|
||||
// Regression guard for the "dragged empty workspace bounces back on refresh"
|
||||
// bug: if the reconciler is ever fed a *partial* workspace set, it drops the
|
||||
// missing workspace and the next call (with the full set) re-adds it at the
|
||||
// top. The watcher avoids this by only reconciling once loading has settled,
|
||||
// but the reconciler's own "drop + re-add at top" behavior is what makes the
|
||||
// guard necessary — pinning it here documents the contract.
|
||||
it('drops a temporarily-absent workspace and re-adds it at the top (why the watcher waits for load)', () => {
|
||||
const dragged = ['ws-b', 'ws-c', 'ws-empty'];
|
||||
const afterPartial = reconcileWorkspaceOrder(['ws-b', 'ws-c'], dragged);
|
||||
expect(afterPartial).toEqual(['ws-b', 'ws-c']);
|
||||
const afterFull = reconcileWorkspaceOrder(['ws-empty', 'ws-b', 'ws-c'], afterPartial!);
|
||||
expect(afterFull).toEqual(['ws-empty', 'ws-b', 'ws-c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortByWorkspaceOrder', () => {
|
||||
const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
|
||||
it('orders items by their position in the order list', () => {
|
||||
expect(sortByWorkspaceOrder(items, ['c', 'a', 'b']).map((x) => x.id)).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('places unknown ids at the front, keeping their relative order', () => {
|
||||
expect(sortByWorkspaceOrder(items, ['b']).map((x) => x.id)).toEqual(['a', 'c', 'b']);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const copy = [...items];
|
||||
sortByWorkspaceOrder(items, ['c', 'a', 'b']);
|
||||
expect(items).toEqual(copy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveInOrder', () => {
|
||||
// The drop indicator is a line at the top (before) or bottom (after) of the
|
||||
// target, so the result must place fromId immediately next to toId.
|
||||
it('moves an item down so it lands before the target', () => {
|
||||
expect(moveInOrder(['a', 'b', 'c', 'd'], 'a', 'c', 'before')).toEqual(['b', 'a', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('moves an item up so it lands before the target', () => {
|
||||
expect(moveInOrder(['a', 'b', 'c', 'd'], 'd', 'b', 'before')).toEqual(['a', 'd', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('inserts after the target when position is "after"', () => {
|
||||
expect(moveInOrder(['a', 'b', 'c', 'd'], 'a', 'c', 'after')).toEqual(['b', 'c', 'a', 'd']);
|
||||
});
|
||||
|
||||
it('can move an item to the very bottom by dropping after the last item', () => {
|
||||
expect(moveInOrder(['A', 'B', 'C'], 'A', 'C', 'after')).toEqual(['B', 'C', 'A']);
|
||||
});
|
||||
|
||||
it('swaps with the adjacent item when dropping after it', () => {
|
||||
expect(moveInOrder(['a', 'b', 'c'], 'a', 'b', 'after')).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
it('is a no-op when dropping before the adjacent item in the indicator direction', () => {
|
||||
// "before b" keeps a above b; to move a below b you drop after b instead.
|
||||
expect(moveInOrder(['a', 'b', 'c'], 'a', 'b', 'before')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('is a no-op when from === to', () => {
|
||||
expect(moveInOrder(['a', 'b', 'c'], 'b', 'b')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('returns the original order when an id is missing', () => {
|
||||
expect(moveInOrder(['a', 'b'], 'x', 'b')).toEqual(['a', 'b']);
|
||||
expect(moveInOrder(['a', 'b'], 'a', 'x')).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
|
@ -104,6 +104,7 @@ function createDeps(): UseWorkspaceStateDeps {
|
|||
refreshSessionStatus: vi.fn(),
|
||||
persistSessionProfile: vi.fn(),
|
||||
mergedWorkspaces: computed(() => []),
|
||||
workspacesView: computed(() => []),
|
||||
status: computed(() => ({})),
|
||||
workspaceIdForSession: vi.fn(),
|
||||
savePermissionToStorage: vi.fn(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue