feat(webui): persist manual session ordering

This commit is contained in:
Xubin Ren
2026-08-06 19:11:05 +08:00
parent 2c8e63446f
commit 9cf6cf0639
12 changed files with 204 additions and 19 deletions
+13
View File
@@ -1596,6 +1596,17 @@ function Shell({
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
);
const onReorderSessions = useCallback(
(sessionOrder: string[]) => {
void updateSidebarState((current) => ({
...current,
session_order: sessionOrder,
view: { ...current.view, sort: "manual" },
}));
},
[updateSidebarState],
);
const onToggleArchived = useCallback(() => {
void updateSidebarState((current) => ({
...current,
@@ -1916,6 +1927,7 @@ function Shell({
onTogglePin,
onRequestRename,
onToggleArchive,
onReorderSessions,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
@@ -1929,6 +1941,7 @@ function Shell({
onToggleArchived,
pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys,
sessionOrder: sidebarState.session_order,
titleOverrides: sidebarState.title_overrides,
projectNameOverrides: sidebarState.project_name_overrides,
collapsedGroups: sidebarState.collapsed_groups,
+84 -9
View File
@@ -56,11 +56,13 @@ interface ChatListProps {
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onReorderSessions?: (keys: string[]) => void;
onToggleGroup?: (groupId: string) => void;
onRequestRenameProject?: (projectKey: string, label: string) => void;
onNewChatInProject?: (projectPath: string, projectName: string) => void;
pinnedKeys?: string[];
archivedKeys?: string[];
sessionOrder?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
@@ -85,11 +87,13 @@ export const ChatList = memo(function ChatList({
onTogglePin,
onRequestRename,
onToggleArchive,
onReorderSessions,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
pinnedKeys = [],
archivedKeys = [],
sessionOrder = [],
titleOverrides = {},
projectNameOverrides = {},
collapsedGroups = {},
@@ -107,6 +111,11 @@ export const ChatList = memo(function ChatList({
}: ChatListProps) {
const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const [draggedSessionKey, setDraggedSessionKey] = useState<string | null>(null);
const [sessionDropTarget, setSessionDropTarget] = useState<{
edge: "before" | "after";
key: string;
} | null>(null);
const activeRowRef = useRef<HTMLDivElement>(null);
const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"),
@@ -124,6 +133,7 @@ export const ChatList = memo(function ChatList({
archivedKeys,
titleOverrides,
projectNameOverrides,
sessionOrder,
showArchived,
sort,
defaultWorkspacePath,
@@ -137,6 +147,7 @@ export const ChatList = memo(function ChatList({
sort,
titleOverrides,
projectNameOverrides,
sessionOrder,
defaultWorkspacePath,
],
);
@@ -156,6 +167,21 @@ export const ChatList = memo(function ChatList({
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
[limitedGroups],
);
const pinned = useMemo(() => new Set(pinnedKeys), [pinnedKeys]);
const archived = useMemo(() => new Set(archivedKeys), [archivedKeys]);
const sessionLanes = useMemo(() => {
const lanes = new Map<string, string>();
for (const group of groups) {
const scope = group.id.startsWith("date:") ? "timeline" : group.id;
for (const session of group.sessions) {
const status = pinned.has(session.key)
? "pinned"
: archived.has(session.key) ? "archived" : "normal";
lanes.set(session.key, `${scope}:${status}`);
}
}
return lanes;
}, [archived, groups, pinned]);
const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount);
useEffect(() => {
@@ -178,13 +204,26 @@ export const ChatList = memo(function ChatList({
);
}
const pinned = new Set(pinnedKeys);
const archived = new Set(archivedKeys);
const running = new Set(runningChatIds);
const updated = new Set(updatedChatIds);
const compact = density === "compact";
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
const canReorderSession = (targetKey: string) => (
!!draggedSessionKey
&& draggedSessionKey !== targetKey
&& sessionLanes.get(draggedSessionKey) === sessionLanes.get(targetKey)
);
const reorderSession = (targetKey: string, edge: "before" | "after") => {
if (!draggedSessionKey || !canReorderSession(targetKey) || !onReorderSessions) return;
const keys = groups.flatMap((group) => group.sessions.map((session) => session.key));
const reordered = keys.filter((key) => key !== draggedSessionKey);
const targetIndex = reordered.indexOf(targetKey);
if (targetIndex < 0) return;
reordered.splice(targetIndex + (edge === "after" ? 1 : 0), 0, draggedSessionKey);
onReorderSessions(reordered);
};
return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<SidebarSelectionHighlight
@@ -261,7 +300,41 @@ export const ChatList = memo(function ChatList({
? "updated"
: null;
return (
<li key={s.key} className="min-w-0">
<li
key={s.key}
className="relative min-w-0"
onDragOver={(event) => {
if (!canReorderSession(s.key)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
const rect = event.currentTarget.getBoundingClientRect();
setSessionDropTarget({
key: s.key,
edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after",
});
}}
onDrop={(event) => {
if (!canReorderSession(s.key)) return;
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
const edge = event.clientY < rect.top + rect.height / 2
? "before"
: "after";
reorderSession(s.key, edge);
setDraggedSessionKey(null);
setSessionDropTarget(null);
}}
>
{sessionDropTarget?.key === s.key ? (
<span
aria-hidden
data-session-drop-edge={sessionDropTarget.edge}
className={cn(
"pointer-events-none absolute inset-x-2 z-20 h-0.5 rounded-full bg-primary",
sessionDropTarget.edge === "before" ? "-top-px" : "-bottom-px",
)}
/>
) : null}
<div
ref={active ? activeRowRef : undefined}
data-chat-row={s.key}
@@ -277,19 +350,21 @@ export const ChatList = memo(function ChatList({
<button
type="button"
onClick={() => onSelect(s.key)}
draggable={!active}
draggable
onDragStart={(event) => {
if (active) {
event.preventDefault();
return;
}
setDraggedSessionKey(s.key);
setSessionDropTarget(null);
writeDraggedSession(event.dataTransfer, s.key);
}}
onDragEnd={() => {
setDraggedSessionKey(null);
setSessionDropTarget(null);
}}
aria-current={active ? "page" : undefined}
title={tooltipTitle}
className={cn(
"min-w-0 flex-1 overflow-hidden text-left",
!active && "cursor-grab active:cursor-grabbing",
"cursor-grab active:cursor-grabbing",
compact ? "py-1" : "py-1.5",
projectMode && "pl-7",
)}
+4
View File
@@ -40,6 +40,7 @@ interface SidebarProps {
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onReorderSessions: (keys: string[]) => void;
onToggleGroup: (groupId: string) => void;
onRequestRenameProject: (projectKey: string, label: string) => void;
onNewChatInProject: (projectPath: string, projectName: string) => void;
@@ -57,6 +58,7 @@ interface SidebarProps {
collapsed?: boolean;
pinnedKeys?: string[];
archivedKeys?: string[];
sessionOrder?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
@@ -227,11 +229,13 @@ export function Sidebar(props: SidebarProps) {
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive}
onReorderSessions={props.onReorderSessions}
onToggleGroup={props.onToggleGroup}
onRequestRenameProject={props.onRequestRenameProject}
onNewChatInProject={props.onNewChatInProject}
pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys}
sessionOrder={props.sessionOrder}
titleOverrides={props.titleOverrides}
projectNameOverrides={props.projectNameOverrides}
collapsedGroups={props.collapsedGroups}
+4 -1
View File
@@ -11,6 +11,7 @@ export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
schema_version: 1,
pinned_keys: [],
archived_keys: [],
session_order: [],
title_overrides: {},
project_name_overrides: {},
tags_by_key: {},
@@ -83,13 +84,14 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
? value.view
: DEFAULT_SIDEBAR_STATE.view;
const density = view.density === "compact" ? "compact" : "comfortable";
const sort = ["updated_desc", "created_desc", "title_asc"].includes(view.sort)
const sort = ["updated_desc", "created_desc", "title_asc", "manual"].includes(view.sort)
? view.sort
: "updated_desc";
return {
schema_version: 1,
pinned_keys: uniqueStrings(value.pinned_keys),
archived_keys: uniqueStrings(value.archived_keys),
session_order: uniqueStrings(value.session_order),
title_overrides: stringMap(value.title_overrides),
project_name_overrides: stringMap(value.project_name_overrides),
tags_by_key: tagsMap(value.tags_by_key),
@@ -122,6 +124,7 @@ function pruneMissingSessions(
...state,
pinned_keys: filterKeys(state.pinned_keys),
archived_keys: filterKeys(state.archived_keys),
session_order: filterKeys(state.session_order),
title_overrides: filterMap(state.title_overrides),
tags_by_key: filterMap(state.tags_by_key),
};
+21 -3
View File
@@ -30,6 +30,7 @@ export interface ChatGroupingOptions {
archivedKeys: string[];
titleOverrides: Record<string, string>;
projectNameOverrides: Record<string, string>;
sessionOrder: string[];
showArchived: boolean;
sort: SidebarSortMode;
defaultWorkspacePath?: string | null;
@@ -64,7 +65,7 @@ export function groupSessions(
pinnedSessions.push(session);
continue;
}
if (options.sort === "title_asc") {
if (options.sort === "title_asc" || options.sort === "manual") {
normalSessions.push(session);
continue;
}
@@ -87,11 +88,12 @@ export function groupSessions(
buckets.get(label) ?? [],
options.sort,
options.titleOverrides,
options.sessionOrder,
),
}))
.filter((group) => group.sessions.length > 0);
if (options.sort === "title_asc" && normalSessions.length) {
if ((options.sort === "title_asc" || options.sort === "manual") && normalSessions.length) {
groups.push({
id: "date:all",
label: labels.all,
@@ -99,6 +101,7 @@ export function groupSessions(
normalSessions,
options.sort,
options.titleOverrides,
options.sessionOrder,
),
});
}
@@ -110,6 +113,7 @@ export function groupSessions(
pinnedSessions,
options.sort,
options.titleOverrides,
options.sessionOrder,
),
});
}
@@ -121,6 +125,7 @@ export function groupSessions(
archivedSessions,
options.sort,
options.titleOverrides,
options.sessionOrder,
),
});
}
@@ -276,6 +281,7 @@ function groupSessionsByProject(
bucket.sessions,
options.sort,
options.titleOverrides,
options.sessionOrder,
pinned,
archived,
),
@@ -297,6 +303,7 @@ function groupSessionsByProject(
conversations,
options.sort,
options.titleOverrides,
options.sessionOrder,
pinned,
archived,
),
@@ -319,10 +326,11 @@ function sortProjectSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
sessionOrder: string[],
pinned: Set<string>,
archived: Set<string>,
): ChatSummary[] {
return sortSessions(sessions, sort, titleOverrides).sort((a, b) => {
return sortSessions(sessions, sort, titleOverrides, sessionOrder).sort((a, b) => {
const pinOrder = Number(pinned.has(b.key)) - Number(pinned.has(a.key));
if (pinOrder !== 0) return pinOrder;
const archiveOrder = Number(archived.has(a.key)) - Number(archived.has(b.key));
@@ -335,9 +343,19 @@ function sortSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
sessionOrder: string[],
): ChatSummary[] {
const copy = [...sessions];
const order = new Map(sessionOrder.map((key, index) => [key, index]));
copy.sort((a, b) => {
if (sort === "manual") {
const aIndex = order.get(a.key);
const bIndex = order.get(b.key);
if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex;
if (aIndex === undefined && bIndex !== undefined) return -1;
if (aIndex !== undefined && bIndex === undefined) return 1;
return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt");
}
if (sort === "title_asc") {
const titleOrder = titleForSort(a, titleOverrides).localeCompare(
titleForSort(b, titleOverrides),
+1 -1
View File
@@ -13,6 +13,6 @@ export function writeDraggedSession(
dataTransfer: DataTransfer,
sessionKey: string,
): void {
dataTransfer.effectAllowed = "copy";
dataTransfer.effectAllowed = "copyMove";
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
}
+2 -1
View File
@@ -367,7 +367,7 @@ export interface WorkspacesPayload {
}
export type SidebarDensity = "comfortable" | "compact";
export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc";
export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc" | "manual";
export interface SidebarViewState {
density: SidebarDensity;
@@ -381,6 +381,7 @@ export interface SidebarStatePayload {
schema_version: number;
pinned_keys: string[];
archived_keys: string[];
session_order: string[];
title_overrides: Record<string, string>;
project_name_overrides: Record<string, string>;
tags_by_key: Record<string, string[]>;
+1
View File
@@ -964,6 +964,7 @@ describe("webui API helpers", () => {
schema_version: 1,
pinned_keys: ["websocket:chat-1"],
archived_keys: ["websocket:old"],
session_order: ["websocket:chat-1", "websocket:old"],
title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
tags_by_key: {},
+63 -2
View File
@@ -48,7 +48,7 @@ describe("ChatList", () => {
vi.unstubAllGlobals();
});
it("exposes inactive chats as session mention drag sources", () => {
it("exposes chats as drag sources", () => {
const dataTransfer = {
effectAllowed: "",
setData: vi.fn(),
@@ -69,7 +69,7 @@ describe("ChatList", () => {
);
expect(screen.getByRole("button", { name: "Active chat" }))
.toHaveAttribute("draggable", "false");
.toHaveAttribute("draggable", "true");
const reference = screen.getByRole("button", { name: "Reference chat" });
expect(reference).toHaveAttribute("draggable", "true");
@@ -81,6 +81,67 @@ describe("ChatList", () => {
);
});
it("reorders chats around a Codex-style insertion line", () => {
const onReorderSessions = vi.fn();
const sessions = [
session({ chatId: "alpha", title: "Alpha" }),
session({ chatId: "bravo", title: "Bravo" }),
session({ chatId: "charlie", title: "Charlie" }),
];
const { rerender } = render(
<ChatList
sessions={sessions}
activeKey={null}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
onReorderSessions={onReorderSessions}
/>,
);
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
};
fireEvent.dragStart(screen.getByRole("button", { name: "Alpha" }), { dataTransfer });
const charlieRow = screen.getByRole("button", { name: "Charlie" }).closest("li")!;
fireEvent.dragOver(charlieRow, { clientY: 1, dataTransfer });
expect(charlieRow.querySelector("[data-session-drop-edge='after']"))
.toBeInTheDocument();
fireEvent.drop(charlieRow, { clientY: 1, dataTransfer });
expect(onReorderSessions).toHaveBeenCalledWith([
"websocket:bravo",
"websocket:charlie",
"websocket:alpha",
]);
rerender(
<ChatList
sessions={sessions}
activeKey={null}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
onReorderSessions={onReorderSessions}
sessionOrder={[
"websocket:bravo",
"websocket:charlie",
"websocket:alpha",
]}
sort="manual"
/>,
);
const section = screen.getByRole("region", { name: "Topics" });
const text = section.textContent ?? "";
expect(text.indexOf("Bravo")).toBeLessThan(text.indexOf("Charlie"));
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
});
it("orders chats by latest session activity by default", () => {
const sessions = [
session({