diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index 7170b6b83..319a9aa63 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -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" diff --git a/nanobot/webui/sidebar_state.py b/nanobot/webui/sidebar_state.py index 08ab708f3..4667dcf01 100644 --- a/nanobot/webui/sidebar_state.py +++ b/nanobot/webui/sidebar_state.py @@ -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") diff --git a/tests/utils/test_webui_sidebar_state.py b/tests/utils/test_webui_sidebar_state.py index 6294a0d5d..d835e9b3e 100644 --- a/tests/utils/test_webui_sidebar_state.py +++ b/tests/utils/test_webui_sidebar_state.py @@ -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"] diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 9b4d67ada..bbcb0ae9d 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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, diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index 1c7cf3980..d64a5792c 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -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; projectNameOverrides?: Record; collapsedGroups?: Record; @@ -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(null); + const [sessionDropTarget, setSessionDropTarget] = useState<{ + edge: "before" | "after"; + key: string; + } | null>(null); const activeRowRef = useRef(null); const labels = useMemo(() => ({ 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(); + 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 (
+
  • { + 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 ? ( + + ) : null}
    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", )} diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 485727569..f8bbd6746 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -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; projectNameOverrides?: Record; collapsedGroups?: Record; @@ -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} diff --git a/webui/src/hooks/useSidebarState.ts b/webui/src/hooks/useSidebarState.ts index bf0a93b52..603574c34 100644 --- a/webui/src/hooks/useSidebarState.ts +++ b/webui/src/hooks/useSidebarState.ts @@ -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), }; diff --git a/webui/src/lib/chat-groups.ts b/webui/src/lib/chat-groups.ts index f4b4b9a1a..008182f17 100644 --- a/webui/src/lib/chat-groups.ts +++ b/webui/src/lib/chat-groups.ts @@ -30,6 +30,7 @@ export interface ChatGroupingOptions { archivedKeys: string[]; titleOverrides: Record; projectNameOverrides: Record; + 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, + sessionOrder: string[], pinned: Set, archived: Set, ): 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, + 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), diff --git a/webui/src/lib/session-drag.ts b/webui/src/lib/session-drag.ts index ee7aecf61..22551ed82 100644 --- a/webui/src/lib/session-drag.ts +++ b/webui/src/lib/session-drag.ts @@ -13,6 +13,6 @@ export function writeDraggedSession( dataTransfer: DataTransfer, sessionKey: string, ): void { - dataTransfer.effectAllowed = "copy"; + dataTransfer.effectAllowed = "copyMove"; dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey); } diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 7aa483fd5..afd734c47 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -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; project_name_overrides: Record; tags_by_key: Record; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index 638ecf0c7..634835031 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -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: {}, diff --git a/webui/src/tests/chat-list.test.tsx b/webui/src/tests/chat-list.test.tsx index b9a3a7937..cb534dedb 100644 --- a/webui/src/tests/chat-list.test.tsx +++ b/webui/src/tests/chat-list.test.tsx @@ -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( + , + ); + 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( + , + ); + 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({