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 = {
|
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"
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[]>;
|
||||||
|
|||||||
@ -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: {},
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user