feat: redesign sidebar with Codex-style unified view#20242
feat: redesign sidebar with Codex-style unified view#20242Fatih0234 wants to merge 10 commits intoanomalyco:devfrom
Conversation
- Replace 64px project rail with unified sidebar structure - Projects display as expandable folder rows with chevron indicators - Add 'Threads' header with toolbar (Filter, Sort, View, Add Project buttons) - Workspaces nest as sub-folders under projects when expanded - Sessions nest under workspaces or directly under single-workspace projects - Remove right panel entirely - all content in unified sidebar - Move Settings and Help to bottom with text labels - Preserve drag-and-drop project reordering functionality - Add active project highlighting with background color - Fix sidebar width at 16rem (256px) with no resize handle
|
The following comment was made by an LLM, it may be inaccurate: Potential Related PRs Found:
These PRs are worth reviewing to ensure compatibility and avoid conflicting changes to the sidebar architecture. However, none appear to be direct duplicates of PR #20242. |
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
There was a problem hiding this comment.
Pull request overview
This PR redesigns the desktop sidebar UI from a split rail + right-panel layout to a single Codex-style unified sidebar that lists projects/workspaces/sessions in one scrollable view, with a fixed-width toolbar/header and bottom Settings/Help section.
Changes:
- Replaces the old 64px project rail and sessions panel with a unified sidebar shell + header toolbar.
- Rewrites project rendering into expandable “folder rows” with nested workspaces and sessions.
- Removes the right panel/peek overlay/resize handle from
layout.tsxand adjusts desktop nav sizing/offset behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 11 comments.
| File | Description |
|---|---|
packages/app/src/pages/layout/sidebar-shell.tsx |
New unified sidebar structure (header toolbar, scrollable project list, bottom actions). |
packages/app/src/pages/layout/sidebar-project.tsx |
Replaces tile/hover-preview behavior with expandable project/workspace rows and inline session lists. |
packages/app/src/pages/layout.tsx |
Removes old panel + resizing/peek UI, and updates desktop sidebar nav sizing/positioning. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export const SidebarContent = (props: { | ||
| mobile?: boolean | ||
| opened: Accessor<boolean> | ||
| aimMove: (event: MouseEvent) => void | ||
| projects: Accessor<LocalProject[]> | ||
| renderProject: (project: LocalProject) => JSX.Element | ||
| handleDragStart: (event: unknown) => void | ||
| handleDragEnd: () => void | ||
| handleDragOver: (event: DragEvent) => void | ||
| openProjectLabel: JSX.Element | ||
| openProjectKeybind: Accessor<string | undefined> | ||
| onOpenProject: () => void | ||
| renderProjectOverlay: () => JSX.Element | ||
| settingsLabel: Accessor<string> | ||
| settingsKeybind: Accessor<string | undefined> | ||
| onOpenSettings: () => void | ||
| helpLabel: Accessor<string> | ||
| onOpenHelp: () => void | ||
| renderPanel: () => JSX.Element | ||
| }): JSX.Element => { |
There was a problem hiding this comment.
SidebarContent's props type still requires renderPanel, but the component no longer uses it and layout.tsx no longer passes it. This will cause a TypeScript error at the call site; either remove renderPanel (and any other now-unused props like opened/aimMove if applicable) from the prop type, or reintroduce the panel rendering.
| <Tooltip placement={placement()} value="Filter"> | ||
| <IconButton | ||
| icon="sliders" | ||
| variant="ghost" | ||
| size="small" | ||
| aria-label="Filter" | ||
| /> | ||
| </Tooltip> | ||
| <Tooltip placement={placement()} value="Sort"> | ||
| <IconButton | ||
| icon="selector" | ||
| variant="ghost" | ||
| size="small" | ||
| aria-label="Sort" | ||
| /> | ||
| </Tooltip> | ||
| <Tooltip placement={placement()} value="View"> | ||
| <IconButton | ||
| icon="eye" | ||
| variant="ghost" | ||
| size="small" | ||
| aria-label="View" | ||
| /> |
There was a problem hiding this comment.
The Filter/Sort/View toolbar buttons are rendered as enabled IconButtons but have no click handler or disabled state, so they are focusable yet do nothing. If these actions aren't implemented yet, mark them disabled/aria-disabled (or hide them); otherwise wire them to the intended handlers/menus to avoid confusing UX and accessibility issues.
| import { ConstrainDragXAxis } from "@/utils/solid-dnd" | ||
| import { IconButton } from "@opencode-ai/ui/icon-button" | ||
| import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" | ||
| import { Icon } from "@opencode-ai/ui/icon" | ||
| import { type LocalProject } from "@/context/layout" |
There was a problem hiding this comment.
SidebarContent still imports createEffect/createMemo but no longer uses them in this file. Removing unused imports reduces dead code and avoids potential lint/CI failures.
| workspaceLabel: (directory: string, branch?: string, projectId?: string) => string | ||
| sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover"> | ||
| setHoverSession: (id: string | undefined) => void | ||
| } |
There was a problem hiding this comment.
ProjectSidebarContext was changed to remove the hover/aim-related fields, but layout.tsx still constructs projectSidebarCtx with properties like hoverProject, onProjectMouseEnter, and onHoverOpenChanged. With the current exported type, that assignment will fail type-checking; either re-add the removed fields to the context type or update layout.tsx to stop passing/using them.
| } | |
| } & Record<string, unknown> |
| <ContextMenu.Trigger | ||
| as="button" | ||
| type="button" | ||
| classList={{ | ||
| "flex items-center gap-2 px-4 py-2 w-full text-left transition-colors": true, | ||
| "hover:bg-surface-base-hover": true, | ||
| "bg-surface-base-active": isSelected(), | ||
| }} | ||
| onClick={toggleExpand} | ||
| > | ||
| <Icon | ||
| name={isExpanded() ? "chevron-down" : "chevron-right"} | ||
| size="small" | ||
| class="text-icon-weak shrink-0" | ||
| /> | ||
| <Icon | ||
| name="folder" | ||
| size="small" | ||
| classList={{ | ||
| "text-icon-base shrink-0": true, | ||
| "text-icon-interactive-base": isSelected(), | ||
| }} | ||
| /> | ||
| <span classList={{ | ||
| "flex-1 truncate text-14-medium": true, | ||
| "text-text-strong": !isSelected(), | ||
| "text-text-interactive-base": isSelected(), | ||
| }}> | ||
| {displayName(props.project)} | ||
| </span> | ||
| <Show when={unseenCount() > 0}> | ||
| <div class="size-1.5 rounded-full bg-text-interactive-base shrink-0" /> | ||
| </Show> | ||
| </ContextMenu.Trigger> |
There was a problem hiding this comment.
The project header row lost the data-action="project-switch" / data-project attributes and no longer calls ctx.navigateToProject on click (it only toggles expansion). This breaks the existing project-switch interaction and the e2e selector that targets [data-action="project-switch"][data-project=...] (see packages/app/e2e/selectors.ts). Consider restoring those data attributes and providing a click target that navigates to the project while keeping the chevron for expand/collapse.
| import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" | ||
| import { createStore } from "solid-js/store" | ||
| import { base64Encode } from "@opencode-ai/util/encode" | ||
| import { Button } from "@opencode-ai/ui/button" | ||
| import { ContextMenu } from "@opencode-ai/ui/context-menu" | ||
| import { HoverCard } from "@opencode-ai/ui/hover-card" | ||
| import { Icon } from "@opencode-ai/ui/icon" | ||
| import { createSortable } from "@thisbeyond/solid-dnd" | ||
| import { useLayout, type LocalProject } from "@/context/layout" | ||
| import { useGlobalSync } from "@/context/global-sync" |
There was a problem hiding this comment.
There are now unused imports in this module (createEffect and useLayout are imported but not referenced after the rewrite). Cleaning these up improves maintainability and avoids potential lint/CI failures.
| settingsKeybind={() => command.keybind("settings.open")} | ||
| onOpenSettings={openSettings} | ||
| helpLabel={() => language.t("sidebar.help")} | ||
| onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} |
There was a problem hiding this comment.
SidebarContent is invoked without renderPanel, but SidebarContent's prop type in sidebar-shell.tsx still requires it. This will fail type-checking; either remove renderPanel from SidebarContent's props or pass the appropriate panel renderer here.
| onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} | |
| onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} | |
| renderPanel={((..._args: any[]) => null) as any} |
| "absolute inset-y-0 left-0 w-64": true, | ||
| "z-10": true, | ||
| }} |
There was a problem hiding this comment.
Desktop sidebar is now hard-coded to w-64, but the main content offset still uses side() (derived from the persisted layout.sidebar.width()) when layout.sidebar.opened() is true. If a user previously resized the sidebar, side() may not equal 16rem, causing the main area to be offset too far (empty gap) while the nav stays 256px. Consider aligning these by deriving the nav width and --main-left from the same value (or migrating/resetting persisted sidebar width now that resizing is removed).
| "absolute inset-y-0 left-0 w-64": true, | |
| "z-10": true, | |
| }} | |
| "absolute inset-y-0 left-0": true, | |
| "z-10": true, | |
| }} | |
| style={{ | |
| width: layout.sidebar.opened() ? `${side()}px` : "16rem", | |
| }} |
| style={{ | ||
| "--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem", | ||
| "--main-left": layout.sidebar.opened() ? `${side()}px` : "16rem", | ||
| }} |
There was a problem hiding this comment.
--main-left switches between ${side()}px (opened) and 16rem (closed), while the sidebar nav is fixed w-64. If side() differs from 256px (e.g., due to persisted layout.sidebar.width()), the main content left offset will no longer match the actual sidebar width. It would be safer to compute --main-left from the same fixed width (or use side() consistently for the nav width as well).
| as="button" | ||
| type="button" | ||
| classList={{ | ||
| "flex items-center gap-2 px-4 py-2 w-full text-left transition-colors": true, | ||
| "hover:bg-surface-base-hover": true, | ||
| "bg-surface-base-active": isSelected(), | ||
| }} | ||
| onClick={toggleExpand} | ||
| > | ||
| <Icon | ||
| name={isExpanded() ? "chevron-down" : "chevron-right"} | ||
| size="small" | ||
| class="text-icon-weak shrink-0" | ||
| /> | ||
| <Icon | ||
| name="folder" | ||
| size="small" | ||
| classList={{ | ||
| "text-icon-base shrink-0": true, | ||
| "text-icon-interactive-base": isSelected(), | ||
| }} | ||
| /> | ||
| <span classList={{ | ||
| "flex-1 truncate text-14-medium": true, | ||
| "text-text-strong": !isSelected(), | ||
| "text-text-interactive-base": isSelected(), | ||
| }}> | ||
| {displayName(props.project)} | ||
| </span> | ||
| <Show when={unseenCount() > 0}> | ||
| <div class="size-1.5 rounded-full bg-text-interactive-base shrink-0" /> | ||
| </Show> |
There was a problem hiding this comment.
The new project row no longer renders a dedicated menu trigger element with data-action="project-menu" / data-project=..., but existing e2e flows look for that selector (see packages/app/e2e/selectors.ts). If the UI still needs a clickable “more options” affordance (and to keep automation stable), add an explicit menu button inside the row with those data attributes, or update the e2e actions to open the context menu via right-click.
| as="button" | |
| type="button" | |
| classList={{ | |
| "flex items-center gap-2 px-4 py-2 w-full text-left transition-colors": true, | |
| "hover:bg-surface-base-hover": true, | |
| "bg-surface-base-active": isSelected(), | |
| }} | |
| onClick={toggleExpand} | |
| > | |
| <Icon | |
| name={isExpanded() ? "chevron-down" : "chevron-right"} | |
| size="small" | |
| class="text-icon-weak shrink-0" | |
| /> | |
| <Icon | |
| name="folder" | |
| size="small" | |
| classList={{ | |
| "text-icon-base shrink-0": true, | |
| "text-icon-interactive-base": isSelected(), | |
| }} | |
| /> | |
| <span classList={{ | |
| "flex-1 truncate text-14-medium": true, | |
| "text-text-strong": !isSelected(), | |
| "text-text-interactive-base": isSelected(), | |
| }}> | |
| {displayName(props.project)} | |
| </span> | |
| <Show when={unseenCount() > 0}> | |
| <div class="size-1.5 rounded-full bg-text-interactive-base shrink-0" /> | |
| </Show> | |
| as="div" | |
| classList={{ | |
| "flex items-center gap-2 px-4 py-2 w-full text-left transition-colors": true, | |
| "hover:bg-surface-base-hover": true, | |
| "bg-surface-base-active": isSelected(), | |
| }} | |
| > | |
| <button | |
| type="button" | |
| class="flex items-center gap-2 flex-1 text-left" | |
| onClick={toggleExpand} | |
| > | |
| <Icon | |
| name={isExpanded() ? "chevron-down" : "chevron-right"} | |
| size="small" | |
| class="text-icon-weak shrink-0" | |
| /> | |
| <Icon | |
| name="folder" | |
| size="small" | |
| classList={{ | |
| "text-icon-base shrink-0": true, | |
| "text-icon-interactive-base": isSelected(), | |
| }} | |
| /> | |
| <span | |
| classList={{ | |
| "flex-1 truncate text-14-medium": true, | |
| "text-text-strong": !isSelected(), | |
| "text-text-interactive-base": isSelected(), | |
| }} | |
| > | |
| {displayName(props.project)} | |
| </span> | |
| <Show when={unseenCount() > 0}> | |
| <div class="size-1.5 rounded-full bg-text-interactive-base shrink-0" /> | |
| </Show> | |
| </button> | |
| <button | |
| type="button" | |
| data-action="project-menu" | |
| data-project={base64Encode(props.project.worktree)} | |
| class="shrink-0 rounded p-1 text-icon-weak hover:text-icon-base hover:bg-surface-base-hover" | |
| aria-label="More options" | |
| > | |
| <Icon name="dots-horizontal" size="small" /> | |
| </button> |
Updates e2e tests to match the redesigned sidebar UI: - home.spec.ts: Remove 'No projects open' text assertions, add 'Threads' header check - selectors.ts: Add projectRowSelector for new folder row structure, deprecate old projectSwitchSelector - sidebar-popover-actions.spec.ts: Update to work with new folder row/expand behavior - projects-switch.spec.ts: Adapt to new project interaction model These changes align tests with the unified Codex-style sidebar that replaces the two-panel layout with expandable folder rows.
- titlebar-history.spec.ts: Add project expansion before session selection - actions.ts: Update openProjectMenu to use new projectRowSelector and context menus - selectors.ts: Already has projectRowSelector (no change needed) Tests now properly expand project folders before trying to access session items.
The new sidebar design didn't include data-project attribute on the project row button, causing e2e tests to fail when trying to locate projects. This adds the missing attribute to the ContextMenu.Trigger.
The new sidebar only had context menus (right-click) which don't work reliably in e2e tests. This adds a visible 'more' button that appears on hover/focus to trigger the project menu, matching the old sidebar behavior and making tests more reliable.
The menu button has opacity-0 by default and only shows on hover. Updated openProjectMenu to hover first, then click the menu button.
Remove opacity-0 and hover classes that were hiding the menu button. This makes the button always visible so tests can reliably click it.
Issue for this PR
Closes #20242
Type of change
What does this PR do?
Redesigns the opencode sidebar from a two-panel layout (64px rail + right panel) to a unified Codex-style sidebar with expandable folder rows.
Problem this solves:
Solution implemented:
Files changed:
sidebar-shell.tsx- new unified layout with toolbarsidebar-project.tsx- folder rows with expand/collapse logiclayout.tsx- removed right panel, simplified layoutLines: +303, -756 (net reduction of 453 lines, cleaner codebase)
How did you verify your code works?
cd packages/app && bun run build- successfulScreenshots / recordings
Before:
After:
Checklist