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:
qer 2026-06-24 12:06:32 +08:00 committed by GitHub
parent b93e9365b6
commit 98d3e5b71d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 504 additions and 39 deletions

View 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.

View file

@ -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"

View file

@ -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) {

View file

@ -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;

View file

@ -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;

View file

@ -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 {

View file

@ -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,

View file

@ -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));
}

View 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;
}

View 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');
});
});

View file

@ -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([]);
});
});

View 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']);
});
});

View file

@ -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(),