mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 01:48:53 +00:00
feat(webui): persist manual session ordering
This commit is contained in:
parent
2c8e63446f
commit
9cf6cf0639
@ -2280,6 +2280,7 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
|
||||
payload = {
|
||||
"pinned_keys": ["websocket:sidebar"],
|
||||
"archived_keys": ["websocket:old"],
|
||||
"session_order": ["websocket:old", "websocket:sidebar"],
|
||||
"title_overrides": {"websocket:sidebar": "Pinned work"},
|
||||
"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
|
||||
body = updated.json()
|
||||
assert body["pinned_keys"] == ["websocket:sidebar"]
|
||||
assert body["session_order"] == ["websocket:old", "websocket:sidebar"]
|
||||
assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"}
|
||||
assert body["view"]["density"] == "compact"
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ _MAX_KEY_LEN = 512
|
||||
_MAX_TITLE_LEN = 160
|
||||
_MAX_TAG_LEN = 40
|
||||
_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:
|
||||
@ -37,6 +37,7 @@ def default_webui_sidebar_state() -> dict[str, Any]:
|
||||
"schema_version": WEBUI_SIDEBAR_STATE_SCHEMA_VERSION,
|
||||
"pinned_keys": [],
|
||||
"archived_keys": [],
|
||||
"session_order": [],
|
||||
"title_overrides": {},
|
||||
"project_name_overrides": {},
|
||||
"tags_by_key": {},
|
||||
@ -138,6 +139,7 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
||||
state = default_webui_sidebar_state()
|
||||
state["pinned_keys"] = _clean_string_list(raw.get("pinned_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["project_name_overrides"] = _clean_title_overrides(
|
||||
raw.get("project_name_overrides")
|
||||
|
||||
@ -26,6 +26,7 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
||||
{
|
||||
"pinned_keys": ["websocket:a", "websocket:a", "", 123],
|
||||
"archived_keys": ["websocket:b"],
|
||||
"session_order": ["websocket:b", "websocket:a", "websocket:b"],
|
||||
"title_overrides": {"websocket:a": " Release notes ", "bad": ""},
|
||||
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
||||
"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["pinned_keys"] == ["websocket:a"]
|
||||
assert state["archived_keys"] == ["websocket:b"]
|
||||
assert state["session_order"] == ["websocket:b", "websocket:a"]
|
||||
assert state["title_overrides"] == {"websocket:a": "Release notes"}
|
||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||
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"],
|
||||
"archived_keys": ["websocket:b"],
|
||||
"session_order": ["websocket:b", "websocket:a"],
|
||||
"title_overrides": {"websocket:a": "Release"},
|
||||
"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["archived_keys"] == ["websocket:b"]
|
||||
assert state["session_order"] == ["websocket:b", "websocket:a"]
|
||||
assert state["title_overrides"] == {"websocket:a": "Release"}
|
||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||
assert state["view"]["density"] == "compact"
|
||||
assert state["view"]["show_previews"] is True
|
||||
assert state["view"]["sort"] == "manual"
|
||||
assert webui_sidebar_state_path().is_file()
|
||||
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -13,6 +13,6 @@ export function writeDraggedSession(
|
||||
dataTransfer: DataTransfer,
|
||||
sessionKey: string,
|
||||
): void {
|
||||
dataTransfer.effectAllowed = "copy";
|
||||
dataTransfer.effectAllowed = "copyMove";
|
||||
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
|
||||
}
|
||||
|
||||
@ -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[]>;
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user