feat(webui): persist manual session ordering

This commit is contained in:
Xubin Ren 2026-08-06 11:19:46 +08:00
parent 2c8e63446f
commit 9cf6cf0639
12 changed files with 204 additions and 19 deletions

View File

@ -2280,6 +2280,7 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
payload = { payload = {
"pinned_keys": ["websocket:sidebar"], "pinned_keys": ["websocket:sidebar"],
"archived_keys": ["websocket:old"], "archived_keys": ["websocket:old"],
"session_order": ["websocket:old", "websocket:sidebar"],
"title_overrides": {"websocket:sidebar": "Pinned work"}, "title_overrides": {"websocket:sidebar": "Pinned work"},
"view": {"density": "compact", "show_archived": True}, "view": {"density": "compact", "show_archived": True},
} }
@ -2291,6 +2292,7 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
assert updated.status_code == 200 assert updated.status_code == 200
body = updated.json() body = updated.json()
assert body["pinned_keys"] == ["websocket:sidebar"] assert body["pinned_keys"] == ["websocket:sidebar"]
assert body["session_order"] == ["websocket:old", "websocket:sidebar"]
assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"} assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"}
assert body["view"]["density"] == "compact" assert body["view"]["density"] == "compact"

View File

@ -25,7 +25,7 @@ _MAX_KEY_LEN = 512
_MAX_TITLE_LEN = 160 _MAX_TITLE_LEN = 160
_MAX_TAG_LEN = 40 _MAX_TAG_LEN = 40
_ALLOWED_DENSITIES = {"comfortable", "compact"} _ALLOWED_DENSITIES = {"comfortable", "compact"}
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc"} _ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
def webui_sidebar_state_path() -> Path: def webui_sidebar_state_path() -> Path:
@ -37,6 +37,7 @@ def default_webui_sidebar_state() -> dict[str, Any]:
"schema_version": WEBUI_SIDEBAR_STATE_SCHEMA_VERSION, "schema_version": WEBUI_SIDEBAR_STATE_SCHEMA_VERSION,
"pinned_keys": [], "pinned_keys": [],
"archived_keys": [], "archived_keys": [],
"session_order": [],
"title_overrides": {}, "title_overrides": {},
"project_name_overrides": {}, "project_name_overrides": {},
"tags_by_key": {}, "tags_by_key": {},
@ -138,6 +139,7 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
state = default_webui_sidebar_state() state = default_webui_sidebar_state()
state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys")) state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys"))
state["archived_keys"] = _clean_string_list(raw.get("archived_keys")) state["archived_keys"] = _clean_string_list(raw.get("archived_keys"))
state["session_order"] = _clean_string_list(raw.get("session_order"))
state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides")) state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides"))
state["project_name_overrides"] = _clean_title_overrides( state["project_name_overrides"] = _clean_title_overrides(
raw.get("project_name_overrides") raw.get("project_name_overrides")

View File

@ -26,6 +26,7 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
{ {
"pinned_keys": ["websocket:a", "websocket:a", "", 123], "pinned_keys": ["websocket:a", "websocket:a", "", 123],
"archived_keys": ["websocket:b"], "archived_keys": ["websocket:b"],
"session_order": ["websocket:b", "websocket:a", "websocket:b"],
"title_overrides": {"websocket:a": " Release notes ", "bad": ""}, "title_overrides": {"websocket:a": " Release notes ", "bad": ""},
"project_name_overrides": {"/repo": " Core ", "bad": ""}, "project_name_overrides": {"/repo": " Core ", "bad": ""},
"tags_by_key": {"websocket:a": ["work", "work", ""]}, "tags_by_key": {"websocket:a": ["work", "work", ""]},
@ -41,6 +42,7 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
assert state["schema_version"] == 1 assert state["schema_version"] == 1
assert state["pinned_keys"] == ["websocket:a"] assert state["pinned_keys"] == ["websocket:a"]
assert state["archived_keys"] == ["websocket:b"] assert state["archived_keys"] == ["websocket:b"]
assert state["session_order"] == ["websocket:b", "websocket:a"]
assert state["title_overrides"] == {"websocket:a": "Release notes"} assert state["title_overrides"] == {"websocket:a": "Release notes"}
assert state["project_name_overrides"] == {"/repo": "Core"} assert state["project_name_overrides"] == {"/repo": "Core"}
assert state["tags_by_key"] == {"websocket:a": ["work"]} assert state["tags_by_key"] == {"websocket:a": ["work"]}
@ -61,17 +63,20 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
{ {
"pinned_keys": ["websocket:a"], "pinned_keys": ["websocket:a"],
"archived_keys": ["websocket:b"], "archived_keys": ["websocket:b"],
"session_order": ["websocket:b", "websocket:a"],
"title_overrides": {"websocket:a": "Release"}, "title_overrides": {"websocket:a": "Release"},
"project_name_overrides": {"/repo": "Core"}, "project_name_overrides": {"/repo": "Core"},
"view": {"density": "compact", "show_previews": True}, "view": {"density": "compact", "show_previews": True, "sort": "manual"},
} }
) )
assert state["pinned_keys"] == ["websocket:a"] assert state["pinned_keys"] == ["websocket:a"]
assert state["archived_keys"] == ["websocket:b"] assert state["archived_keys"] == ["websocket:b"]
assert state["session_order"] == ["websocket:b", "websocket:a"]
assert state["title_overrides"] == {"websocket:a": "Release"} assert state["title_overrides"] == {"websocket:a": "Release"}
assert state["project_name_overrides"] == {"/repo": "Core"} assert state["project_name_overrides"] == {"/repo": "Core"}
assert state["view"]["density"] == "compact" assert state["view"]["density"] == "compact"
assert state["view"]["show_previews"] is True assert state["view"]["show_previews"] is True
assert state["view"]["sort"] == "manual"
assert webui_sidebar_state_path().is_file() assert webui_sidebar_state_path().is_file()
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"] assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]

View File

@ -1596,6 +1596,17 @@ function Shell({
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState], [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(() => { const onToggleArchived = useCallback(() => {
void updateSidebarState((current) => ({ void updateSidebarState((current) => ({
...current, ...current,
@ -1916,6 +1927,7 @@ function Shell({
onTogglePin, onTogglePin,
onRequestRename, onRequestRename,
onToggleArchive, onToggleArchive,
onReorderSessions,
onToggleGroup, onToggleGroup,
onRequestRenameProject, onRequestRenameProject,
onNewChatInProject, onNewChatInProject,
@ -1929,6 +1941,7 @@ function Shell({
onToggleArchived, onToggleArchived,
pinnedKeys: sidebarState.pinned_keys, pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys, archivedKeys: sidebarState.archived_keys,
sessionOrder: sidebarState.session_order,
titleOverrides: sidebarState.title_overrides, titleOverrides: sidebarState.title_overrides,
projectNameOverrides: sidebarState.project_name_overrides, projectNameOverrides: sidebarState.project_name_overrides,
collapsedGroups: sidebarState.collapsed_groups, collapsedGroups: sidebarState.collapsed_groups,

View File

@ -56,11 +56,13 @@ interface ChatListProps {
onTogglePin: (key: string) => void; onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void; onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void; onToggleArchive: (key: string) => void;
onReorderSessions?: (keys: string[]) => void;
onToggleGroup?: (groupId: string) => void; onToggleGroup?: (groupId: string) => void;
onRequestRenameProject?: (projectKey: string, label: string) => void; onRequestRenameProject?: (projectKey: string, label: string) => void;
onNewChatInProject?: (projectPath: string, projectName: string) => void; onNewChatInProject?: (projectPath: string, projectName: string) => void;
pinnedKeys?: string[]; pinnedKeys?: string[];
archivedKeys?: string[]; archivedKeys?: string[];
sessionOrder?: string[];
titleOverrides?: Record<string, string>; titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>; projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>; collapsedGroups?: Record<string, boolean>;
@ -85,11 +87,13 @@ export const ChatList = memo(function ChatList({
onTogglePin, onTogglePin,
onRequestRename, onRequestRename,
onToggleArchive, onToggleArchive,
onReorderSessions,
onToggleGroup, onToggleGroup,
onRequestRenameProject, onRequestRenameProject,
onNewChatInProject, onNewChatInProject,
pinnedKeys = [], pinnedKeys = [],
archivedKeys = [], archivedKeys = [],
sessionOrder = [],
titleOverrides = {}, titleOverrides = {},
projectNameOverrides = {}, projectNameOverrides = {},
collapsedGroups = {}, collapsedGroups = {},
@ -107,6 +111,11 @@ export const ChatList = memo(function ChatList({
}: ChatListProps) { }: ChatListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS); 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 activeRowRef = useRef<HTMLDivElement>(null);
const labels = useMemo<ChatGroupLabels>(() => ({ const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"), pinned: t("chat.groups.pinned"),
@ -124,6 +133,7 @@ export const ChatList = memo(function ChatList({
archivedKeys, archivedKeys,
titleOverrides, titleOverrides,
projectNameOverrides, projectNameOverrides,
sessionOrder,
showArchived, showArchived,
sort, sort,
defaultWorkspacePath, defaultWorkspacePath,
@ -137,6 +147,7 @@ export const ChatList = memo(function ChatList({
sort, sort,
titleOverrides, titleOverrides,
projectNameOverrides, projectNameOverrides,
sessionOrder,
defaultWorkspacePath, defaultWorkspacePath,
], ],
); );
@ -156,6 +167,21 @@ export const ChatList = memo(function ChatList({
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0), () => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
[limitedGroups], [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); const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount);
useEffect(() => { 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 running = new Set(runningChatIds);
const updated = new Set(updatedChatIds); const updated = new Set(updatedChatIds);
const compact = density === "compact"; const compact = density === "compact";
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project"); 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 ( return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent"> <div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<SidebarSelectionHighlight <SidebarSelectionHighlight
@ -261,7 +300,41 @@ export const ChatList = memo(function ChatList({
? "updated" ? "updated"
: null; : null;
return ( 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 <div
ref={active ? activeRowRef : undefined} ref={active ? activeRowRef : undefined}
data-chat-row={s.key} data-chat-row={s.key}
@ -277,19 +350,21 @@ export const ChatList = memo(function ChatList({
<button <button
type="button" type="button"
onClick={() => onSelect(s.key)} onClick={() => onSelect(s.key)}
draggable={!active} draggable
onDragStart={(event) => { onDragStart={(event) => {
if (active) { setDraggedSessionKey(s.key);
event.preventDefault(); setSessionDropTarget(null);
return;
}
writeDraggedSession(event.dataTransfer, s.key); writeDraggedSession(event.dataTransfer, s.key);
}} }}
onDragEnd={() => {
setDraggedSessionKey(null);
setSessionDropTarget(null);
}}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
title={tooltipTitle} title={tooltipTitle}
className={cn( className={cn(
"min-w-0 flex-1 overflow-hidden text-left", "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", compact ? "py-1" : "py-1.5",
projectMode && "pl-7", projectMode && "pl-7",
)} )}

View File

@ -40,6 +40,7 @@ interface SidebarProps {
onTogglePin: (key: string) => void; onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void; onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void; onToggleArchive: (key: string) => void;
onReorderSessions: (keys: string[]) => void;
onToggleGroup: (groupId: string) => void; onToggleGroup: (groupId: string) => void;
onRequestRenameProject: (projectKey: string, label: string) => void; onRequestRenameProject: (projectKey: string, label: string) => void;
onNewChatInProject: (projectPath: string, projectName: string) => void; onNewChatInProject: (projectPath: string, projectName: string) => void;
@ -57,6 +58,7 @@ interface SidebarProps {
collapsed?: boolean; collapsed?: boolean;
pinnedKeys?: string[]; pinnedKeys?: string[];
archivedKeys?: string[]; archivedKeys?: string[];
sessionOrder?: string[];
titleOverrides?: Record<string, string>; titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>; projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>; collapsedGroups?: Record<string, boolean>;
@ -227,11 +229,13 @@ export function Sidebar(props: SidebarProps) {
onTogglePin={props.onTogglePin} onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename} onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive} onToggleArchive={props.onToggleArchive}
onReorderSessions={props.onReorderSessions}
onToggleGroup={props.onToggleGroup} onToggleGroup={props.onToggleGroup}
onRequestRenameProject={props.onRequestRenameProject} onRequestRenameProject={props.onRequestRenameProject}
onNewChatInProject={props.onNewChatInProject} onNewChatInProject={props.onNewChatInProject}
pinnedKeys={props.pinnedKeys} pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys} archivedKeys={props.archivedKeys}
sessionOrder={props.sessionOrder}
titleOverrides={props.titleOverrides} titleOverrides={props.titleOverrides}
projectNameOverrides={props.projectNameOverrides} projectNameOverrides={props.projectNameOverrides}
collapsedGroups={props.collapsedGroups} collapsedGroups={props.collapsedGroups}

View File

@ -11,6 +11,7 @@ export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
schema_version: 1, schema_version: 1,
pinned_keys: [], pinned_keys: [],
archived_keys: [], archived_keys: [],
session_order: [],
title_overrides: {}, title_overrides: {},
project_name_overrides: {}, project_name_overrides: {},
tags_by_key: {}, tags_by_key: {},
@ -83,13 +84,14 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
? value.view ? value.view
: DEFAULT_SIDEBAR_STATE.view; : DEFAULT_SIDEBAR_STATE.view;
const density = view.density === "compact" ? "compact" : "comfortable"; 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 ? view.sort
: "updated_desc"; : "updated_desc";
return { return {
schema_version: 1, schema_version: 1,
pinned_keys: uniqueStrings(value.pinned_keys), pinned_keys: uniqueStrings(value.pinned_keys),
archived_keys: uniqueStrings(value.archived_keys), archived_keys: uniqueStrings(value.archived_keys),
session_order: uniqueStrings(value.session_order),
title_overrides: stringMap(value.title_overrides), title_overrides: stringMap(value.title_overrides),
project_name_overrides: stringMap(value.project_name_overrides), project_name_overrides: stringMap(value.project_name_overrides),
tags_by_key: tagsMap(value.tags_by_key), tags_by_key: tagsMap(value.tags_by_key),
@ -122,6 +124,7 @@ function pruneMissingSessions(
...state, ...state,
pinned_keys: filterKeys(state.pinned_keys), pinned_keys: filterKeys(state.pinned_keys),
archived_keys: filterKeys(state.archived_keys), archived_keys: filterKeys(state.archived_keys),
session_order: filterKeys(state.session_order),
title_overrides: filterMap(state.title_overrides), title_overrides: filterMap(state.title_overrides),
tags_by_key: filterMap(state.tags_by_key), tags_by_key: filterMap(state.tags_by_key),
}; };

View File

@ -30,6 +30,7 @@ export interface ChatGroupingOptions {
archivedKeys: string[]; archivedKeys: string[];
titleOverrides: Record<string, string>; titleOverrides: Record<string, string>;
projectNameOverrides: Record<string, string>; projectNameOverrides: Record<string, string>;
sessionOrder: string[];
showArchived: boolean; showArchived: boolean;
sort: SidebarSortMode; sort: SidebarSortMode;
defaultWorkspacePath?: string | null; defaultWorkspacePath?: string | null;
@ -64,7 +65,7 @@ export function groupSessions(
pinnedSessions.push(session); pinnedSessions.push(session);
continue; continue;
} }
if (options.sort === "title_asc") { if (options.sort === "title_asc" || options.sort === "manual") {
normalSessions.push(session); normalSessions.push(session);
continue; continue;
} }
@ -87,11 +88,12 @@ export function groupSessions(
buckets.get(label) ?? [], buckets.get(label) ?? [],
options.sort, options.sort,
options.titleOverrides, options.titleOverrides,
options.sessionOrder,
), ),
})) }))
.filter((group) => group.sessions.length > 0); .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({ groups.push({
id: "date:all", id: "date:all",
label: labels.all, label: labels.all,
@ -99,6 +101,7 @@ export function groupSessions(
normalSessions, normalSessions,
options.sort, options.sort,
options.titleOverrides, options.titleOverrides,
options.sessionOrder,
), ),
}); });
} }
@ -110,6 +113,7 @@ export function groupSessions(
pinnedSessions, pinnedSessions,
options.sort, options.sort,
options.titleOverrides, options.titleOverrides,
options.sessionOrder,
), ),
}); });
} }
@ -121,6 +125,7 @@ export function groupSessions(
archivedSessions, archivedSessions,
options.sort, options.sort,
options.titleOverrides, options.titleOverrides,
options.sessionOrder,
), ),
}); });
} }
@ -276,6 +281,7 @@ function groupSessionsByProject(
bucket.sessions, bucket.sessions,
options.sort, options.sort,
options.titleOverrides, options.titleOverrides,
options.sessionOrder,
pinned, pinned,
archived, archived,
), ),
@ -297,6 +303,7 @@ function groupSessionsByProject(
conversations, conversations,
options.sort, options.sort,
options.titleOverrides, options.titleOverrides,
options.sessionOrder,
pinned, pinned,
archived, archived,
), ),
@ -319,10 +326,11 @@ function sortProjectSessions(
sessions: ChatSummary[], sessions: ChatSummary[],
sort: SidebarSortMode, sort: SidebarSortMode,
titleOverrides: Record<string, string>, titleOverrides: Record<string, string>,
sessionOrder: string[],
pinned: Set<string>, pinned: Set<string>,
archived: Set<string>, archived: Set<string>,
): ChatSummary[] { ): 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)); const pinOrder = Number(pinned.has(b.key)) - Number(pinned.has(a.key));
if (pinOrder !== 0) return pinOrder; if (pinOrder !== 0) return pinOrder;
const archiveOrder = Number(archived.has(a.key)) - Number(archived.has(b.key)); const archiveOrder = Number(archived.has(a.key)) - Number(archived.has(b.key));
@ -335,9 +343,19 @@ function sortSessions(
sessions: ChatSummary[], sessions: ChatSummary[],
sort: SidebarSortMode, sort: SidebarSortMode,
titleOverrides: Record<string, string>, titleOverrides: Record<string, string>,
sessionOrder: string[],
): ChatSummary[] { ): ChatSummary[] {
const copy = [...sessions]; const copy = [...sessions];
const order = new Map(sessionOrder.map((key, index) => [key, index]));
copy.sort((a, b) => { 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") { if (sort === "title_asc") {
const titleOrder = titleForSort(a, titleOverrides).localeCompare( const titleOrder = titleForSort(a, titleOverrides).localeCompare(
titleForSort(b, titleOverrides), titleForSort(b, titleOverrides),

View File

@ -13,6 +13,6 @@ export function writeDraggedSession(
dataTransfer: DataTransfer, dataTransfer: DataTransfer,
sessionKey: string, sessionKey: string,
): void { ): void {
dataTransfer.effectAllowed = "copy"; dataTransfer.effectAllowed = "copyMove";
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey); dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
} }

View File

@ -367,7 +367,7 @@ export interface WorkspacesPayload {
} }
export type SidebarDensity = "comfortable" | "compact"; 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 { export interface SidebarViewState {
density: SidebarDensity; density: SidebarDensity;
@ -381,6 +381,7 @@ export interface SidebarStatePayload {
schema_version: number; schema_version: number;
pinned_keys: string[]; pinned_keys: string[];
archived_keys: string[]; archived_keys: string[];
session_order: string[];
title_overrides: Record<string, string>; title_overrides: Record<string, string>;
project_name_overrides: Record<string, string>; project_name_overrides: Record<string, string>;
tags_by_key: Record<string, string[]>; tags_by_key: Record<string, string[]>;

View File

@ -964,6 +964,7 @@ describe("webui API helpers", () => {
schema_version: 1, schema_version: 1,
pinned_keys: ["websocket:chat-1"], pinned_keys: ["websocket:chat-1"],
archived_keys: ["websocket:old"], archived_keys: ["websocket:old"],
session_order: ["websocket:chat-1", "websocket:old"],
title_overrides: { "websocket:chat-1": "Release" }, title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" }, project_name_overrides: { "/Users/me/nanobot": "Core" },
tags_by_key: {}, tags_by_key: {},

View File

@ -48,7 +48,7 @@ describe("ChatList", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("exposes inactive chats as session mention drag sources", () => { it("exposes chats as drag sources", () => {
const dataTransfer = { const dataTransfer = {
effectAllowed: "", effectAllowed: "",
setData: vi.fn(), setData: vi.fn(),
@ -69,7 +69,7 @@ describe("ChatList", () => {
); );
expect(screen.getByRole("button", { name: "Active chat" })) expect(screen.getByRole("button", { name: "Active chat" }))
.toHaveAttribute("draggable", "false"); .toHaveAttribute("draggable", "true");
const reference = screen.getByRole("button", { name: "Reference chat" }); const reference = screen.getByRole("button", { name: "Reference chat" });
expect(reference).toHaveAttribute("draggable", "true"); 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", () => { it("orders chats by latest session activity by default", () => {
const sessions = [ const sessions = [
session({ session({