From f1028f0ff0244a87941d457ea29c93a9a94296df Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Mon, 11 May 2026 10:18:28 -0700 Subject: [PATCH 01/28] fix: simplify sidebar nav and add settings-focused rails Remove the legacy settings submenu from the left sidebar, move key actions into sticky footer controls, and add a dedicated settings-page left rail so navigation matches the new layout and interaction flow. Co-authored-by: Cursor --- .../settings/settings-navigation.test.tsx | 11 +- .../features/sidebar/sidebar.test.tsx | 22 +-- .../features/backends/backend-selector.tsx | 113 ++++++----- .../features/settings/settings-navigation.tsx | 27 +++ .../sidebar/sidebar-conversation-list.tsx | 12 +- src/components/features/sidebar/sidebar.tsx | 176 ++---------------- src/constants/settings-nav.tsx | 12 +- 7 files changed, 125 insertions(+), 248 deletions(-) diff --git a/__tests__/components/features/settings/settings-navigation.test.tsx b/__tests__/components/features/settings/settings-navigation.test.tsx index c58eaebb2..b23b34e86 100644 --- a/__tests__/components/features/settings/settings-navigation.test.tsx +++ b/__tests__/components/features/settings/settings-navigation.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router"; @@ -27,8 +27,10 @@ describe("SettingsNavigation", () => { expect(screen.getByTestId("settings-navbar")).toBeInTheDocument(); expect(screen.getAllByText("SETTINGS$TITLE").length).toBeGreaterThan(0); - expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument(); - expect(screen.getByText("SETTINGS$NAV_CONDENSER")).toBeInTheDocument(); + expect(screen.getAllByText("SETTINGS$NAV_LLM").length).toBeGreaterThan(0); + expect( + screen.getAllByText("SETTINGS$NAV_CONDENSER").length, + ).toBeGreaterThan(0); }); it("closes the mobile drawer when the close button is clicked", async () => { @@ -62,7 +64,8 @@ describe("SettingsNavigation", () => { , ); - await userEvent.click(screen.getByText("SETTINGS$NAV_LLM")); + const mobileNav = screen.getByTestId("settings-navbar"); + await userEvent.click(within(mobileNav).getByText("SETTINGS$NAV_LLM")); expect(onCloseMobileMenu).toHaveBeenCalledTimes(1); }); diff --git a/__tests__/components/features/sidebar/sidebar.test.tsx b/__tests__/components/features/sidebar/sidebar.test.tsx index 8548b2a7f..0dc2d0493 100644 --- a/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/__tests__/components/features/sidebar/sidebar.test.tsx @@ -70,6 +70,10 @@ vi.mock("#/components/features/backends/backend-selector", () => ({ BackendSelector: () =>
, })); +vi.mock("#/components/features/conversation-panel/new-conversation-button", () => ({ + NewConversationButton: () =>
, +})); + vi.mock("#/components/features/sidebar/sidebar-conversation-list", () => ({ SidebarConversationList: () =>
, })); @@ -116,14 +120,11 @@ describe("Sidebar", () => { }, ); - it("renders sidebar nav links and the settings toggle with the default text color shared by the settings page nav (text-[#8C8C8C])", () => { + it("renders sidebar nav links with the default text color (text-[#8C8C8C])", () => { renderSidebar("/skills"); const conversationsLink = screen.getByTestId("sidebar-conversations-link"); expect(conversationsLink.className).toMatch(/(^|\s)text-\[#8C8C8C\](\s|$)/); - - const settingsToggle = screen.getByTestId("sidebar-settings-toggle"); - expect(settingsToggle.className).toMatch(/(^|\s)text-\[#8C8C8C\](\s|$)/); }); it("toggles between expanded and collapsed states and persists the choice", () => { @@ -131,19 +132,11 @@ describe("Sidebar", () => { const sidebar = screen.getByRole("navigation").parentElement; expect(sidebar?.dataset.collapsed).toBe("false"); - // Settings inline toggle is rendered in expanded mode only. - expect(screen.getByTestId("sidebar-settings-toggle")).toBeInTheDocument(); const toggle = screen.getByTestId("sidebar-collapse-toggle"); fireEvent.click(toggle); expect(sidebar?.dataset.collapsed).toBe("true"); - // Inline settings submenu toggle disappears in the collapsed rail; the - // single Settings link remains as the icon entry point. - expect( - screen.queryByTestId("sidebar-settings-toggle"), - ).not.toBeInTheDocument(); - expect(screen.getByTestId("sidebar-settings-link")).toBeInTheDocument(); // The choice survives a remount via localStorage. unmount(); @@ -160,11 +153,9 @@ describe("Sidebar", () => { // Act fireEvent.click(screen.getByTestId("sidebar-collapse-toggle")); - // Assert: state flips back to expanded and the inline settings submenu - // toggle (rendered only when expanded) reappears. + // Assert: state flips back to expanded. const sidebar = screen.getByRole("navigation").parentElement; expect(sidebar?.dataset.collapsed).toBe("false"); - expect(screen.getByTestId("sidebar-settings-toggle")).toBeInTheDocument(); }); it("renders icons for every top-level nav item so they remain meaningful in the collapsed rail", () => { @@ -174,7 +165,6 @@ describe("Sidebar", () => { "sidebar-conversations-link", "sidebar-automations-link", "sidebar-skills-link", - "sidebar-settings-toggle", ]) { const link = screen.getByTestId(testId); expect(link.querySelector("svg")).not.toBeNull(); diff --git a/src/components/features/backends/backend-selector.tsx b/src/components/features/backends/backend-selector.tsx index 40da85477..326271d44 100644 --- a/src/components/features/backends/backend-selector.tsx +++ b/src/components/features/backends/backend-selector.tsx @@ -252,58 +252,71 @@ export function BackendSelector({ return ( <> - { - if (!item || item.value === activeValue) return; - const { backendId, orgId } = parseOptionValue(item.value); - const target = backends.find((b) => b.id === backendId); - if (!target) return; - - triggerEnvironmentSwitch(item.label); - await new Promise((resolve) => { - setTimeout(resolve, ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS); - }); - - if (orgId && target.kind === "cloud") { - try { - await switchOrg({ orgId, backend: target }); - } catch (error) { - dismissEnvironmentSwitch(); - - if (!axios.isAxiosError(error)) { - console.error("Unexpected error during org switch:", error); - displayErrorToast(t(I18nKey.ERROR$GENERIC)); - return; +
+
+ + footer={addBackendFooter} + openUpward={openUpward} + onChange={async (item) => { + if (!item || item.value === activeValue) return; + const { backendId, orgId } = parseOptionValue(item.value); + const target = backends.find((b) => b.id === backendId); + if (!target) return; + + triggerEnvironmentSwitch(item.label); + await new Promise((resolve) => { + setTimeout(resolve, ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS); + }); + + if (orgId && target.kind === "cloud") { + try { + await switchOrg({ orgId, backend: target }); + } catch (error) { + dismissEnvironmentSwitch(); + + if (!axios.isAxiosError(error)) { + console.error("Unexpected error during org switch:", error); + displayErrorToast(t(I18nKey.ERROR$GENERIC)); + return; + } + + displayErrorToast( + retrieveAxiosErrorMessage(error) || t(I18nKey.ERROR$GENERIC), + ); + return; + } + } + + if (conversationMatch) navigate("/conversations"); + else if (automationDetailMatch) navigate("/automations"); + + setActive(target.id, orgId); + }} + placeholder={active.backend.name} + loading={someCloudLoading || isSwitching} + options={options} + className="bg-[#1F1F1F66] border-[#242424]" + /> +
+ +
{addBackendModalOpen ? ( setAddBackendModalOpen(false)} /> ) : null} diff --git a/src/components/features/settings/settings-navigation.tsx b/src/components/features/settings/settings-navigation.tsx index 887eb3cfc..adbb63096 100644 --- a/src/components/features/settings/settings-navigation.tsx +++ b/src/components/features/settings/settings-navigation.tsx @@ -8,6 +8,7 @@ import { SettingsNavRenderedItem } from "#/hooks/use-settings-nav-items"; import { SettingsNavHeader } from "./settings-nav-header"; import { SettingsNavDivider } from "./settings-nav-divider"; import { SettingsNavLink } from "./settings-nav-link"; +import { SidebarNavLink } from "#/components/features/sidebar/sidebar-nav-link"; interface SettingsNavigationProps { isMobileMenuOpen: boolean; @@ -21,9 +22,35 @@ export function SettingsNavigation({ navigationItems, }: SettingsNavigationProps) { const { t } = useTranslation("openhands"); + const desktopNavItems = navigationItems.filter( + (item): item is Extract => + item.type === "item", + ); return ( <> + + {isMobileMenuOpen && (
-
- -
diff --git a/src/components/features/sidebar/sidebar.tsx b/src/components/features/sidebar/sidebar.tsx index 801492b1e..c8df3b380 100644 --- a/src/components/features/sidebar/sidebar.tsx +++ b/src/components/features/sidebar/sidebar.tsx @@ -9,16 +9,14 @@ import { displayErrorToast } from "#/utils/custom-toast-handlers"; import { I18nKey } from "#/i18n/declaration"; import { useNavigation } from "#/context/navigation-context"; import { cn } from "#/utils/utils"; -import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; import { BackendSelector } from "#/components/features/backends/backend-selector"; -import { OSS_NAV_ITEMS } from "#/constants/settings-nav"; import { SidebarConversationList } from "./sidebar-conversation-list"; import { SidebarCollapseContext } from "./sidebar-collapse-context"; import { useSidebarCollapsedState } from "#/hooks/use-sidebar-collapsed"; +import { NewConversationButton } from "#/components/features/conversation-panel/new-conversation-button"; import MessageIcon from "#/icons/message.svg?react"; import AutomationsIcon from "#/icons/automations.svg?react"; import SparkleIcon from "#/icons/sparkle.svg?react"; -import CogIcon from "#/icons/cog.svg?react"; // The LLM settings modal is only mounted when the settings query 404s and // LLM settings aren't hidden — keep it out of the sidebar's eager graph. @@ -28,10 +26,6 @@ const SettingsModal = React.lazy(() => })), ); -const SETTINGS_NAV_ICON_BY_PATH = new Map( - OSS_NAV_ITEMS.map((item) => [item.to, item.icon] as const), -); - const ICON_SIZE = 18; export function Sidebar() { @@ -44,23 +38,10 @@ export function Sidebar() { isError: settingsIsError, isFetching: isFetchingSettings, } = useSettings(); - const settingsNavItems = useSettingsNavItems(); - const [collapsed, setCollapsed] = useSidebarCollapsedState(); const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false); const settingsErrorStatus = getErrorStatus(settingsError); - const isSettingsActive = currentPath.startsWith("/settings"); - const [settingsExpanded, setSettingsExpanded] = - React.useState(isSettingsActive); - - // Auto-expand the settings submenu whenever we navigate into /settings. - React.useEffect(() => { - if (isSettingsActive) { - setSettingsExpanded(true); - } - }, [isSettingsActive]); - React.useEffect(() => { if (currentPath === "/settings") { setSettingsModalIsOpen(false); @@ -90,48 +71,6 @@ export function Sidebar() { const linkDisabled = settings?.email_verified === false; - // Floating panel rendered in the hover tooltip when the (collapsed) - // Settings button is hovered: lists the same submenu the expanded variant - // shows inline. - const settingsHoverPanel = ( -
-
- {t(I18nKey.SIDEBAR$SETTINGS)} -
-
    - {settingsNavItems.map((rendered) => { - if (rendered.type !== "item") return null; - const navIcon = SETTINGS_NAV_ICON_BY_PATH.get(rendered.item.to); - return ( -
  • - , - { width: 16, height: 16 }, - ) - : undefined - } - /> -
  • - ); - })} -
-
- ); - const collapseToggleLabel = t( collapsed ? I18nKey.SIDEBAR$EXPAND : I18nKey.SIDEBAR$COLLAPSE, ); @@ -193,14 +132,9 @@ export function Sidebar() {
- {/* Hide the backend selector when collapsed — it is a wide dropdown - that doesn't compress meaningfully into a 56px rail. Users who - want to switch backends can expand the sidebar first. */} - {!collapsed && ( -
- -
- )} +
+ +
+ + {/* Sidebar footer: keep backend selector pinned to the bottom with a + visual separator above it. Hidden in collapsed mode because the + control needs full-width space. */} + {!collapsed && ( +
+ +
+ )} {settingsModalIsOpen && ( diff --git a/src/constants/settings-nav.tsx b/src/constants/settings-nav.tsx index 5473228a6..57085270f 100644 --- a/src/constants/settings-nav.tsx +++ b/src/constants/settings-nav.tsx @@ -13,32 +13,32 @@ export interface SettingsNavItem { export const OSS_NAV_ITEMS: SettingsNavItem[] = [ { - icon: , + icon: , to: "/settings", text: "SETTINGS$NAV_LLM", }, { - icon: , + icon: , to: "/settings/condenser", text: "SETTINGS$NAV_CONDENSER", }, { - icon: , + icon: , to: "/settings/verification", text: "SETTINGS$NAV_VERIFICATION", }, { - icon: , + icon: , to: "/settings/mcp", text: "SETTINGS$NAV_MCP", }, { - icon: , + icon: , to: "/settings/app", text: "SETTINGS$NAV_APPLICATION", }, { - icon: , + icon: , to: "/settings/secrets", text: "SETTINGS$NAV_SECRETS", }, From 5cfed2a8ecf8a5bd1bac36e917ed5ff8c0af79f8 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Mon, 11 May 2026 10:27:37 -0700 Subject: [PATCH 02/28] fix: improve collapsed sidebar expand affordance Make the collapsed rail open reliably from empty-space clicks and hover by swapping the logo area to an explicit expand control. Also update the OpenHands logo asset to use white/transparent fills for the requested visual treatment. Co-authored-by: Cursor --- .../features/sidebar/sidebar.test.tsx | 15 +++ src/assets/branding/openhands-logo.svg | 14 +-- src/components/features/sidebar/sidebar.tsx | 112 ++++++++++++++---- 3 files changed, 109 insertions(+), 32 deletions(-) diff --git a/__tests__/components/features/sidebar/sidebar.test.tsx b/__tests__/components/features/sidebar/sidebar.test.tsx index 0dc2d0493..f723807b3 100644 --- a/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/__tests__/components/features/sidebar/sidebar.test.tsx @@ -158,6 +158,21 @@ describe("Sidebar", () => { expect(sidebar?.dataset.collapsed).toBe("false"); }); + it("expands the sidebar when collapsed rail empty space is clicked", () => { + window.localStorage.setItem("openhands-sidebar-collapsed", "true"); + renderSidebar("/conversations"); + + const sidebar = screen.getByRole("navigation").parentElement; + expect(sidebar?.dataset.collapsed).toBe("true"); + + if (!sidebar) { + throw new Error("Sidebar root not found"); + } + + fireEvent.click(sidebar); + expect(sidebar.dataset.collapsed).toBe("false"); + }); + it("renders icons for every top-level nav item so they remain meaningful in the collapsed rail", () => { renderSidebar("/conversations"); diff --git a/src/assets/branding/openhands-logo.svg b/src/assets/branding/openhands-logo.svg index 3aa40d440..f6938f73a 100644 --- a/src/assets/branding/openhands-logo.svg +++ b/src/assets/branding/openhands-logo.svg @@ -1,9 +1,9 @@ - - - - - - - + + + + + + + diff --git a/src/components/features/sidebar/sidebar.tsx b/src/components/features/sidebar/sidebar.tsx index c8df3b380..e1638b4f1 100644 --- a/src/components/features/sidebar/sidebar.tsx +++ b/src/components/features/sidebar/sidebar.tsx @@ -40,6 +40,7 @@ export function Sidebar() { } = useSettings(); const [collapsed, setCollapsed] = useSidebarCollapsedState(); const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false); + const [collapsedRailHovered, setCollapsedRailHovered] = React.useState(false); const settingsErrorStatus = getErrorStatus(settingsError); React.useEffect(() => { @@ -74,12 +75,47 @@ export function Sidebar() { const collapseToggleLabel = t( collapsed ? I18nKey.SIDEBAR$EXPAND : I18nKey.SIDEBAR$COLLAPSE, ); + const handleCollapsedRailClick = React.useCallback( + (event: React.MouseEvent) => { + if (!collapsed) { + return; + } + + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + // Keep existing behavior for explicit controls/links and only use + // this as a convenience hit-area for empty collapsed-rail space. + if ( + target.closest( + "a,button,input,textarea,select,[role='button'],[role='link']", + ) + ) { + return; + } + + setCollapsed(false); + }, + [collapsed, setCollapsed], + ); + const showCollapsedExpandButton = collapsed && collapsedRailHovered; return (
+ ) : ( + <> + + {/* Desktop-only collapse toggle. Hidden on mobile (the sidebar + there is the top bar and doesn't collapse). No tooltip — + the chevron direction already conveys what the button does. */} + + )} + > + + + + )}
From 3c3107c11c67e30429db6aa7bc380c043fb09e14 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Mon, 11 May 2026 10:43:32 -0700 Subject: [PATCH 03/28] fix: refresh conversation panel filtering and sidebar visual separation This aligns the conversation list behavior with the new older-conversation filter/menu flow and adds a clear desktop divider for the left navigation rail to improve layout clarity. Co-authored-by: Cursor --- .../conversation-panel.test.tsx | 115 +++++++++++++----- .../compact-conversation-row.tsx | 3 + .../conversation-card-footer.tsx | 16 ++- .../conversation-card/conversation-card.tsx | 3 + .../conversation-panel/conversation-panel.tsx | 100 +++++++++++---- src/components/features/sidebar/sidebar.tsx | 1 + 6 files changed, 177 insertions(+), 61 deletions(-) diff --git a/__tests__/components/features/conversation-panel/conversation-panel.test.tsx b/__tests__/components/features/conversation-panel/conversation-panel.test.tsx index eeaecd30b..584474b91 100644 --- a/__tests__/components/features/conversation-panel/conversation-panel.test.tsx +++ b/__tests__/components/features/conversation-panel/conversation-panel.test.tsx @@ -33,7 +33,7 @@ vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({ // Helper to create complete AppConversation mock data // Default timestamps use "now" so conversations are considered recent and -// rendered eagerly by the panel (which hides items older than ~1h by default). +// rendered eagerly by the panel. const createMockConversation = ( overrides: Partial = {}, ): AppConversation => ({ @@ -332,7 +332,7 @@ describe("ConversationPanel", () => { expect(newCards).toHaveLength(3); }); - it("keeps invalid timestamps recent and only shows load more after expanding older conversations", async () => { + it("keeps invalid timestamps recent and shows older conversations by default", async () => { const now = Date.now(); const minutesAgo = (minutes: number) => new Date(now - minutes * 60 * 1000).toISOString(); @@ -385,17 +385,13 @@ describe("ConversationPanel", () => { expect(await screen.findByText("Recent Conversation")).toBeInTheDocument(); expect(screen.getByText("Invalid Timestamp")).toBeInTheDocument(); expect(screen.getByText("Missing Timestamp")).toBeInTheDocument(); - expect(screen.queryByText("Older Conversation")).not.toBeInTheDocument(); + expect(screen.getByText("Older Conversation")).toBeInTheDocument(); expect( screen.getByTestId("older-conversations-summary"), ).toBeInTheDocument(); expect( - screen.queryByTestId("load-more-conversations"), - ).not.toBeInTheDocument(); - - await user.click(screen.getByTestId("toggle-older-conversations")); - - expect(await screen.findByText("Older Conversation")).toBeInTheDocument(); + screen.getByTestId("load-more-conversations"), + ).toBeInTheDocument(); await user.click(screen.getByTestId("load-more-conversations")); @@ -1014,7 +1010,7 @@ describe("ConversationPanel", () => { const olderIso = () => new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); - it("hides conversations older than 1h behind a summary line", async () => { + it("shows conversations older than 1h and includes a summary line", async () => { vi.spyOn( AgentServerConversationService, "searchConversations", @@ -1042,18 +1038,16 @@ describe("ConversationPanel", () => { renderConversationPanel(); const cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(1); - expect(within(cards[0]).getByText("Recent")).toBeInTheDocument(); + expect(cards).toHaveLength(3); + expect(screen.getByText("Recent")).toBeInTheDocument(); + expect(screen.getByText("Old 1")).toBeInTheDocument(); + expect(screen.getByText("Old 2")).toBeInTheDocument(); const summary = screen.getByTestId("older-conversations-summary"); - expect(summary).toHaveTextContent("2"); - expect(summary).toHaveTextContent("CONVERSATION$N_OLDER_CONVERSATIONS"); - expect( - within(summary).getByTestId("toggle-older-conversations"), - ).toHaveTextContent("CONVERSATION$SHOW_ALL"); + expect(summary).toHaveTextContent("Conversations"); expect( - within(summary).getByTestId("delete-older-conversations"), - ).toHaveTextContent("CONVERSATION$DELETE_ALL"); + within(summary).getByTestId("older-conversations-filter-toggle"), + ).toBeInTheDocument(); }); it("does not render the summary when no conversations are older than 1h", async () => { @@ -1084,7 +1078,7 @@ describe("ConversationPanel", () => { ).not.toBeInTheDocument(); }); - it("toggles older conversations visibility via the show-all link", async () => { + it("toggles older conversations visibility via the filter dropdown", async () => { const user = userEvent.setup(); vi.spyOn( AgentServerConversationService, @@ -1108,19 +1102,67 @@ describe("ConversationPanel", () => { renderConversationPanel(); let cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(1); + expect(cards).toHaveLength(2); - const toggle = screen.getByTestId("toggle-older-conversations"); - expect(toggle).toHaveTextContent("CONVERSATION$SHOW_ALL"); + await user.click(screen.getByTestId("older-conversations-filter-toggle")); + let toggle = await screen.findByTestId("toggle-older-conversations"); + expect(toggle).toHaveTextContent("CONVERSATION$HIDE"); await user.click(toggle); cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(2); - expect(toggle).toHaveTextContent("CONVERSATION$HIDE"); + expect(cards).toHaveLength(1); + await user.click(screen.getByTestId("older-conversations-filter-toggle")); + toggle = await screen.findByTestId("toggle-older-conversations"); + expect(toggle).toHaveTextContent("CONVERSATION$SHOW_ALL"); await user.click(toggle); cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(1); + expect(cards).toHaveLength(2); + }); + + it("keeps repo/branch metadata hidden by default and toggles it from the filter dropdown", async () => { + const user = userEvent.setup(); + vi.spyOn( + AgentServerConversationService, + "searchConversations", + ).mockResolvedValue({ + items: [ + createMockConversation({ + id: "recent", + title: "Recent", + updated_at: recentIso(), + }), + createMockConversation({ + id: "old-with-repo", + title: "Old With Repo", + updated_at: olderIso(), + selected_repository: "openhands/agent-canvas", + selected_branch: "main", + git_provider: "github", + }), + ], + next_page_id: null, + }); + + renderConversationPanel(); + await screen.findByText("Old With Repo"); + + expect( + screen.queryByTestId("conversation-card-selected-repository"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("conversation-card-selected-branch"), + ).not.toBeInTheDocument(); + + await user.click(screen.getByTestId("older-conversations-filter-toggle")); + await user.click(screen.getByTestId("toggle-repo-branch-metadata")); + + expect( + await screen.findByTestId("conversation-card-selected-repository"), + ).toHaveTextContent("openhands/agent-canvas"); + expect( + await screen.findByTestId("conversation-card-selected-branch"), + ).toHaveTextContent("main"); }); it("delete-all confirms then deletes every older conversation", async () => { @@ -1156,6 +1198,7 @@ describe("ConversationPanel", () => { renderConversationPanel(); await screen.findAllByTestId("conversation-card"); + await user.click(screen.getByTestId("older-conversations-filter-toggle")); await user.click(screen.getByTestId("delete-older-conversations")); const confirmButton = await screen.findByRole("button", { @@ -1209,6 +1252,7 @@ describe("ConversationPanel", () => { }); await screen.findAllByTestId("conversation-card"); + await user.click(screen.getByTestId("older-conversations-filter-toggle")); await user.click(screen.getByTestId("delete-older-conversations")); await user.click(await screen.findByRole("button", { name: /confirm/i })); @@ -1260,6 +1304,7 @@ describe("ConversationPanel", () => { }); await screen.findAllByTestId("conversation-card"); + await user.click(screen.getByTestId("older-conversations-filter-toggle")); await user.click(screen.getByTestId("delete-older-conversations")); await user.click(await screen.findByRole("button", { name: /confirm/i })); @@ -1345,7 +1390,7 @@ describe("ConversationPanel", () => { expect(loadMore).toHaveTextContent("CONVERSATION$LOAD_MORE"); }); - it("hides the load-more link while older conversations are hidden", async () => { + it("hides the load-more link after older conversations are hidden from the filter dropdown", async () => { vi.spyOn( AgentServerConversationService, "searchConversations", @@ -1368,13 +1413,23 @@ describe("ConversationPanel", () => { renderConversationPanel(); await screen.findAllByTestId("conversation-card"); - // Older conversations are present and collapsed → no load-more. + // Older conversations are visible by default, so load-more is visible. + expect( + screen.getByTestId("load-more-conversations"), + ).toBeInTheDocument(); + + // Hide older conversations via the filter dropdown. + const user = userEvent.setup(); + await user.click(screen.getByTestId("older-conversations-filter-toggle")); + await user.click(screen.getByTestId("toggle-older-conversations")); + + // Older conversations are hidden → no load-more. expect( screen.queryByTestId("load-more-conversations"), ).not.toBeInTheDocument(); - // After expanding "show all", the link reappears. - const user = userEvent.setup(); + // After showing older conversations again, the link reappears. + await user.click(screen.getByTestId("older-conversations-filter-toggle")); await user.click(screen.getByTestId("toggle-older-conversations")); expect( await screen.findByTestId("load-more-conversations"), diff --git a/src/components/features/conversation-panel/compact-conversation-row.tsx b/src/components/features/conversation-panel/compact-conversation-row.tsx index 8a50341bc..7550ab70c 100644 --- a/src/components/features/conversation-panel/compact-conversation-row.tsx +++ b/src/components/features/conversation-panel/compact-conversation-row.tsx @@ -17,6 +17,7 @@ interface CompactConversationRowProps { workspaceWorkingDir?: string | null; isActive?: boolean; onClose?: () => void; + showRepositoryMetadata?: boolean; } /** @@ -34,6 +35,7 @@ export function CompactConversationRow({ workspaceWorkingDir, isActive = false, onClose, + showRepositoryMetadata = true, }: CompactConversationRowProps) { const disableAnimation = import.meta.env.MODE === "test"; @@ -54,6 +56,7 @@ export function CompactConversationRow({ createdAt={createdAt} executionStatus={executionStatus} workspaceWorkingDir={workspaceWorkingDir} + showRepositoryMetadata={showRepositoryMetadata} />
); diff --git a/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx b/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx index 39c0c9764..e748dc743 100644 --- a/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx +++ b/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx @@ -14,6 +14,7 @@ interface ConversationCardFooterProps { createdAt?: string; executionStatus?: ExecutionStatus | null; workspaceWorkingDir?: string | null; + showRepositoryMetadata?: boolean; } export function ConversationCardFooter({ @@ -22,6 +23,7 @@ export function ConversationCardFooter({ createdAt, executionStatus, workspaceWorkingDir, + showRepositoryMetadata = true, }: ConversationCardFooterProps) { const { t } = useTranslation("openhands"); @@ -32,15 +34,17 @@ export function ConversationCardFooter({ className={cn( // Left padding aligns the repo/workspace icon with the title text in // the header (status dot 10px + gap-2 8px = 18px). - "flex flex-row justify-between items-center mt-1 pl-[18px]", + "flex flex-row justify-between items-center mt-1", + showRepositoryMetadata && "pl-[18px]", isPaused && "opacity-60", )} > - {selectedRepository?.selected_repository ? ( - - ) : ( - - )} + {showRepositoryMetadata && + (selectedRepository?.selected_repository ? ( + + ) : ( + + ))}
{(createdAt ?? lastUpdatedAt) && (

diff --git a/src/components/features/conversation-panel/conversation-card/conversation-card.tsx b/src/components/features/conversation-panel/conversation-card/conversation-card.tsx index 41b9d11c3..1e3c70177 100644 --- a/src/components/features/conversation-panel/conversation-card/conversation-card.tsx +++ b/src/components/features/conversation-panel/conversation-card/conversation-card.tsx @@ -26,6 +26,7 @@ interface ConversationCardProps { onContextMenuToggle?: (isOpen: boolean) => void; isActive?: boolean; workspaceWorkingDir?: string | null; + showRepositoryMetadata?: boolean; } export function ConversationCard({ @@ -44,6 +45,7 @@ export function ConversationCard({ onContextMenuToggle, isActive = false, workspaceWorkingDir, + showRepositoryMetadata = true, }: ConversationCardProps) { const posthog = usePostHog(); const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view"); @@ -161,6 +163,7 @@ export function ConversationCard({ createdAt={createdAt} executionStatus={executionStatus} workspaceWorkingDir={workspaceWorkingDir} + showRepositoryMetadata={showRepositoryMetadata} />

); diff --git a/src/components/features/conversation-panel/conversation-panel.tsx b/src/components/features/conversation-panel/conversation-panel.tsx index 5e53bc5f0..29edc7127 100644 --- a/src/components/features/conversation-panel/conversation-panel.tsx +++ b/src/components/features/conversation-panel/conversation-panel.tsx @@ -1,5 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; +import { SlidersHorizontal } from "lucide-react"; import { I18nKey } from "#/i18n/declaration"; import { useNavigation } from "#/context/navigation-context"; import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations"; @@ -36,6 +37,8 @@ interface ConversationPanelProps { const noop = () => {}; const ONE_HOUR_MS = 60 * 60 * 1000; +const capitalizeLabel = (label: string) => + label.length > 0 ? label.charAt(0).toUpperCase() + label.slice(1) : label; const partitionByCutoff = ( items: readonly T[], @@ -82,7 +85,13 @@ export function ConversationPanel({ const [confirmDeleteOlderVisible, setConfirmDeleteOlderVisible] = React.useState(false); const [showOlderConversations, setShowOlderConversations] = + React.useState(true); + const [olderFilterMenuOpen, setOlderFilterMenuOpen] = React.useState(false); + const [showRepoBranchMetadata, setShowRepoBranchMetadata] = React.useState(false); + const olderFilterMenuRef = useClickOutsideElement(() => { + setOlderFilterMenuOpen(false); + }); const [selectedConversationId, setSelectedConversationId] = React.useState< string | null >(null); @@ -223,6 +232,7 @@ export function ConversationPanel({ workspaceWorkingDir={conversation.workspace?.working_dir} isActive={conversation.id === currentConversationId} onClose={onClose} + showRepositoryMetadata={showRepoBranchMetadata} /> ); } @@ -257,6 +267,7 @@ export function ConversationPanel({ } isActive={conversation.id === currentConversationId} workspaceWorkingDir={conversation.workspace?.working_dir} + showRepositoryMetadata={showRepoBranchMetadata} /> ); @@ -269,6 +280,7 @@ export function ConversationPanel({ handleStopConversation, onClose, openContextMenuId, + showRepoBranchMetadata, ], ); @@ -331,36 +343,74 @@ export function ConversationPanel({ {!compact && olderConversations.length > 0 && (
- - {olderConversations.length}{" "} - {t(I18nKey.CONVERSATION$N_OLDER_CONVERSATIONS)}: + + Conversations - - +
+ + + {olderFilterMenuOpen && ( +
+ + +
+ +
+ )} +
)} - {/* Older conversations only render when explicitly expanded via - "Show all". Compact mode mirrors expanded's default — recent - only — so the icon rail isn't packed with archive cruft. */} + {/* Older conversations render by default; users can hide them from the + summary's filter menu. Compact mode still omits the summary row. */} {!compact && showOlderConversations && olderConversations.map(renderConversationCard)} diff --git a/src/components/features/sidebar/sidebar.tsx b/src/components/features/sidebar/sidebar.tsx index e1638b4f1..2fa28143a 100644 --- a/src/components/features/sidebar/sidebar.tsx +++ b/src/components/features/sidebar/sidebar.tsx @@ -118,6 +118,7 @@ export function Sidebar() { }} className={cn( "bg-base flex flex-col gap-3 transition-[width,min-width] duration-200", + "md:border-r md:border-[#242424]", // Mobile: top bar; Desktop: vertical column. Width responds to // the collapsed state on md+ screens. "h-[54px] md:h-full", From 00622b218912b4df94f72ba95b82892d1337e5de Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Mon, 11 May 2026 10:45:22 -0700 Subject: [PATCH 04/28] fix: prevent conversation list horizontal overflow This makes repo/branch metadata rows shrink and truncate correctly and enforces horizontal clipping in the conversation list scroller so long labels never introduce sideways scrolling. Co-authored-by: Cursor --- .../conversation-card/conversation-card-footer.tsx | 4 ++-- .../conversation-card/conversation-repo-link.tsx | 10 +++++----- .../conversation-card/no-repository.tsx | 8 ++++---- .../features/conversation-panel/conversation-panel.tsx | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx b/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx index e748dc743..d6a58b47c 100644 --- a/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx +++ b/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx @@ -34,7 +34,7 @@ export function ConversationCardFooter({ className={cn( // Left padding aligns the repo/workspace icon with the title text in // the header (status dot 10px + gap-2 8px = 18px). - "flex flex-row justify-between items-center mt-1", + "flex flex-row items-center gap-2 mt-1 w-full min-w-0", showRepositoryMetadata && "pl-[18px]", isPaused && "opacity-60", )} @@ -45,7 +45,7 @@ export function ConversationCardFooter({ ) : ( ))} -
+
{(createdAt ?? lastUpdatedAt) && (

-
+
+
{Icon && } {selectedRepository.git_provider === "azure_devops" && ( )} {selectedRepository.selected_repository}
-
+
{selectedRepository.selected_branch} diff --git a/src/components/features/conversation-panel/conversation-card/no-repository.tsx b/src/components/features/conversation-panel/conversation-card/no-repository.tsx index 6fb6ed0e9..a4a1a870c 100644 --- a/src/components/features/conversation-panel/conversation-card/no-repository.tsx +++ b/src/components/features/conversation-panel/conversation-card/no-repository.tsx @@ -18,11 +18,11 @@ export function NoRepository({ workspaceWorkingDir }: NoRepositoryProps) { if (folderName) { return (
- + {folderName}
@@ -30,9 +30,9 @@ export function NoRepository({ workspaceWorkingDir }: NoRepositoryProps) { } return ( -
+
- {t(I18nKey.COMMON$NO_REPOSITORY)} + {t(I18nKey.COMMON$NO_REPOSITORY)}
); } diff --git a/src/components/features/conversation-panel/conversation-panel.tsx b/src/components/features/conversation-panel/conversation-panel.tsx index 29edc7127..e72b3fed5 100644 --- a/src/components/features/conversation-panel/conversation-panel.tsx +++ b/src/components/features/conversation-panel/conversation-panel.tsx @@ -298,7 +298,7 @@ export function ConversationPanel({ data-testid="conversation-panel" className="w-full h-full flex flex-col" > -
+
{showInitialSkeleton && (
{Array.from({ length: 5 }).map((_, index) => ( From f2abb9b7bbdff3b74fc987c3a5c93a2d1b585b0c Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Mon, 11 May 2026 11:29:46 -0700 Subject: [PATCH 05/28] fix: refine sidebar conversation list layout and interactions This updates the left-nav conversation list styling/spacing and keeps the older-conversations controls fixed above the scroll region, while tightening dropdown/timestamp behavior to match the intended hover and layering UX. Co-authored-by: Cursor --- .../conversation-panel.test.tsx | 19 +- .../conversation-card-actions.tsx | 8 +- .../conversation-card-footer.tsx | 6 +- .../conversation-card-header.tsx | 2 +- .../conversation-card-skeleton.test.tsx | 8 + .../conversation-card-skeleton.tsx | 19 +- .../conversation-card/conversation-card.tsx | 60 +++++-- .../conversation-panel/conversation-panel.tsx | 166 +++++++++--------- src/components/features/sidebar/sidebar.tsx | 6 +- 9 files changed, 179 insertions(+), 115 deletions(-) diff --git a/__tests__/components/features/conversation-panel/conversation-panel.test.tsx b/__tests__/components/features/conversation-panel/conversation-panel.test.tsx index 584474b91..559366059 100644 --- a/__tests__/components/features/conversation-panel/conversation-panel.test.tsx +++ b/__tests__/components/features/conversation-panel/conversation-panel.test.tsx @@ -1,7 +1,6 @@ import { screen, waitFor, - waitForElementToBeRemoved, within, } from "@testing-library/react"; import { @@ -163,17 +162,18 @@ describe("ConversationPanel", () => { renderWithProviders(); - const skeletons = await screen.findAllByTestId( - "conversation-card-skeleton", - ); - await waitForElementToBeRemoved(skeletons); + await waitFor(() => { + expect( + screen.queryByTestId("conversation-card-skeleton-compact"), + ).not.toBeInTheDocument(); + }); expect( screen.queryByText("CONVERSATION$NO_CONVERSATIONS"), ).not.toBeInTheDocument(); }); - it("should handle an error when fetching conversations", async () => { + it("should not render fetch errors in the conversation panel", async () => { const searchConversationsSpy = vi.spyOn( AgentServerConversationService, "searchConversations", @@ -184,8 +184,11 @@ describe("ConversationPanel", () => { renderConversationPanel(); - const error = await screen.findByText("Failed to fetch conversations"); - expect(error).toBeInTheDocument(); + await waitFor(() => { + expect( + screen.queryByText("Failed to fetch conversations"), + ).not.toBeInTheDocument(); + }); }); it("should cancel deleting a conversation", async () => { diff --git a/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx b/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx index f2a0ce0e8..35cabbdbd 100644 --- a/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx +++ b/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx @@ -34,7 +34,7 @@ export function ConversationCardActions({ const isActive = isExecutionActive(executionStatus); return ( -
+
+ + {olderFilterMenuOpen && ( +
+ + +
+ +
+ )} +
+
+ )} + +
{ + setIsListScrolled(event.currentTarget.scrollTop > 0); + }} + className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden overscroll-contain custom-scrollbar-always" + > {showInitialSkeleton && (
{Array.from({ length: 5 }).map((_, index) => ( - + ))}
)} - {error && ( -
-

{error.message}

-
- )} - {!compact && showEmptyState && (

@@ -338,77 +413,6 @@ export function ConversationPanel({ {/* Recent conversations (last_updated within the past hour) */} {recentConversations.map(renderConversationCard)} - {/* Older conversations: full summary in expanded mode, just a flat - list of dots in compact mode (the summary text can't fit). */} - {!compact && olderConversations.length > 0 && ( -

- - Conversations - -
- - - {olderFilterMenuOpen && ( -
- - -
- -
- )} -
-
- )} - {/* Older conversations render by default; users can hide them from the summary's filter menu. Compact mode still omits the summary row. */} {!compact && diff --git a/src/components/features/sidebar/sidebar.tsx b/src/components/features/sidebar/sidebar.tsx index 2fa28143a..54f673123 100644 --- a/src/components/features/sidebar/sidebar.tsx +++ b/src/components/features/sidebar/sidebar.tsx @@ -117,7 +117,7 @@ export function Sidebar() { setCollapsedRailHovered(false); }} className={cn( - "bg-base flex flex-col gap-3 transition-[width,min-width] duration-200", + "bg-base flex flex-col transition-[width,min-width] duration-200", "md:border-r md:border-[#242424]", // Mobile: top bar; Desktop: vertical column. Width responds to // the collapsed state on md+ screens. @@ -132,7 +132,7 @@ export function Sidebar() { > -
- -
+ {/* + Temporarily hide the dedicated New Conversation button and surface + creation via the first nav entry instead. + */}