summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2026-03-06 22:33:34 +0000
committerGitHub <[email protected]>2026-03-06 16:33:34 -0600
commitb0bc3d87f59fb28340fc4c047131919031890898 (patch)
tree35c56cbdb069fb720de642d7a4cc0bd6332e3747 /packages/app/src
parenta2634337b84643c08df5337243e8f82399c85615 (diff)
downloadopencode-b0bc3d87f59fb28340fc4c047131919031890898.tar.gz
opencode-b0bc3d87f59fb28340fc4c047131919031890898.zip
feat(app): sidebar reveal animation, hover peek overlay, and weaker dividers (#16374)
Co-authored-by: Adam <[email protected]>
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/pages/layout.tsx200
-rw-r--r--packages/app/src/pages/layout/sidebar-shell.tsx26
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx4
-rw-r--r--packages/app/src/pages/session/session-side-panel.tsx4
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx4
5 files changed, 191 insertions, 47 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index cf2c3b6c4..c9f6ae26f 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -155,6 +155,8 @@ export default function Layout(props: ParentProps) {
const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
const navLeave = { current: undefined as number | undefined }
const [sortNow, setSortNow] = createSignal(Date.now())
+ const [sizing, setSizing] = createSignal(false)
+ let sizet: number | undefined
let sortNowInterval: ReturnType<typeof setInterval> | undefined
const sortNowTimeout = setTimeout(
() => {
@@ -167,7 +169,7 @@ export default function Layout(props: ParentProps) {
const aim = createAim({
enabled: () => !layout.sidebar.opened(),
active: () => state.hoverProject,
- el: () => state.nav,
+ el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
onActivate: (directory) => {
globalSync.child(directory)
setState("hoverProject", directory)
@@ -179,9 +181,23 @@ export default function Layout(props: ParentProps) {
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
clearTimeout(sortNowTimeout)
if (sortNowInterval) clearInterval(sortNowInterval)
+ if (sizet !== undefined) clearTimeout(sizet)
+ if (peekt !== undefined) clearTimeout(peekt)
aim.reset()
})
+ onMount(() => {
+ const stop = () => setSizing(false)
+ window.addEventListener("pointerup", stop)
+ window.addEventListener("pointercancel", stop)
+ window.addEventListener("blur", stop)
+ onCleanup(() => {
+ window.removeEventListener("pointerup", stop)
+ window.removeEventListener("pointercancel", stop)
+ window.removeEventListener("blur", stop)
+ })
+ })
+
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
const setHoverProject = (value: string | undefined) => {
@@ -192,6 +208,27 @@ export default function Layout(props: ParentProps) {
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
+ const disarm = () => {
+ if (navLeave.current === undefined) return
+ clearTimeout(navLeave.current)
+ navLeave.current = undefined
+ }
+
+ const arm = () => {
+ if (layout.sidebar.opened()) return
+ if (state.hoverProject === undefined) return
+ disarm()
+ navLeave.current = window.setTimeout(() => {
+ navLeave.current = undefined
+ setHoverProject(undefined)
+ setState("hoverSession", undefined)
+ }, 300)
+ }
+
+ const [peek, setPeek] = createSignal<LocalProject | undefined>(undefined)
+ const [peeked, setPeeked] = createSignal(false)
+ let peekt: number | undefined
+
const hoverProjectData = createMemo(() => {
const id = state.hoverProject
if (!id) return
@@ -199,6 +236,27 @@ export default function Layout(props: ParentProps) {
})
createEffect(() => {
+ const p = hoverProjectData()
+ if (p) {
+ if (peekt !== undefined) {
+ clearTimeout(peekt)
+ peekt = undefined
+ }
+ setPeek(p)
+ setPeeked(true)
+ return
+ }
+
+ setPeeked(false)
+ if (peek() === undefined) return
+ if (peekt !== undefined) clearTimeout(peekt)
+ peekt = window.setTimeout(() => {
+ peekt = undefined
+ setPeek(undefined)
+ }, 180)
+ })
+
+ createEffect(() => {
if (!layout.sidebar.opened()) return
setHoverProject(undefined)
})
@@ -1123,6 +1181,12 @@ export default function Layout(props: ParentProps) {
}
const openSession = async (target: { directory: string; id: string }) => {
if (!canOpen(target.directory)) return false
+ const [data] = globalSync.child(target.directory, { bootstrap: false })
+ if (data.session.some((item) => item.id === target.id)) {
+ setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
+ navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
+ return true
+ }
const resolved = await globalSDK.client.session
.get({ sessionID: target.id })
.then((x) => x.data)
@@ -1813,7 +1877,8 @@ export default function Layout(props: ParentProps) {
setHoverSession,
}
- const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
+ const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
+ const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const projectName = createMemo(() => {
const project = panelProps.project
if (!project) return ""
@@ -1839,10 +1904,17 @@ export default function Layout(props: ParentProps) {
return (
<div
classList={{
- "flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true,
+ "flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true,
+ "border border-b-0 border-border-weak-base": !merged(),
+ "border-l border-t border-border-weaker-base": merged(),
+ "bg-background-base": merged(),
+ "bg-background-stronger": !merged(),
"flex-1 min-w-0": panelProps.mobile,
+ "max-w-full overflow-hidden": panelProps.mobile,
+ }}
+ style={{
+ width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
}}
- style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
>
<Show when={panelProps.project}>
{(p) => (
@@ -2041,33 +2113,27 @@ export default function Layout(props: ParentProps) {
return (
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar />
- <div class="flex-1 min-h-0 flex">
+ <div class="flex-1 min-h-0 relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
- "relative shrink-0": true,
+ "absolute inset-y-0 left-0": true,
+ "z-10": true,
}}
- style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
+ style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
- if (navLeave.current === undefined) return
- clearTimeout(navLeave.current)
- navLeave.current = undefined
+ disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
- if (navLeave.current !== undefined) clearTimeout(navLeave.current)
- navLeave.current = window.setTimeout(() => {
- navLeave.current = undefined
- setHoverProject(undefined)
- setState("hoverSession", undefined)
- }, 300)
+ arm()
}}
>
<div class="@container w-full h-full contain-strict">
@@ -2094,28 +2160,36 @@ export default function Layout(props: ParentProps) {
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => (
<Show when={currentProject()} keyed>
- {(project) => <SidebarPanel project={project} />}
+ {(project) => <SidebarPanel project={project} merged />}
</Show>
)}
/>
</div>
- <Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
- <div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
- <SidebarPanel project={hoverProjectData()} />
- </div>
- </Show>
<Show when={layout.sidebar.opened()}>
- <ResizeHandle
- direction="horizontal"
- size={layout.sidebar.width()}
- min={244}
- max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
- collapseThreshold={244}
- onResize={layout.sidebar.resize}
- onCollapse={layout.sidebar.close}
- />
+ <div onPointerDown={() => setSizing(true)}>
+ <ResizeHandle
+ direction="horizontal"
+ size={layout.sidebar.width()}
+ min={244}
+ max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
+ collapseThreshold={244}
+ onResize={(w) => {
+ setSizing(true)
+ if (sizet !== undefined) clearTimeout(sizet)
+ sizet = window.setTimeout(() => setSizing(false), 120)
+ layout.sidebar.resize(w)
+ }}
+ onCollapse={layout.sidebar.close}
+ />
+ </div>
</Show>
</nav>
+
+ <div
+ class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
+ style={{ left: "calc(4rem + 12px)" }}
+ />
+
<div class="xl:hidden">
<div
classList={{
@@ -2131,7 +2205,7 @@ export default function Layout(props: ParentProps) {
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
- "@container fixed top-10 bottom-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
+ "@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
@@ -2164,16 +2238,66 @@ export default function Layout(props: ParentProps) {
</nav>
</div>
- <main
+ <div
classList={{
- "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
- "xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
+ "absolute inset-0": true,
+ "xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
+ "z-20": true,
+ "transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
+ !sizing(),
+ }}
+ style={{
+ "--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
}}
>
- <Show when={!autoselecting()} fallback={<div class="size-full" />}>
- {props.children}
+ <main
+ classList={{
+ "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base xl:border-l xl:rounded-tl-[12px]": true,
+ }}
+ >
+ <Show when={!autoselecting()} fallback={<div class="size-full" />}>
+ {props.children}
+ </Show>
+ </main>
+ </div>
+
+ <div
+ classList={{
+ "hidden xl:flex absolute inset-y-0 left-16 z-30": true,
+ "opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
+ "opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
+ "transition-[opacity,transform] motion-reduce:transition-none": true,
+ "duration-180 ease-out": peeked() && !layout.sidebar.opened(),
+ "duration-120 ease-in": !peeked() || layout.sidebar.opened(),
+ }}
+ onMouseMove={disarm}
+ onMouseEnter={() => {
+ disarm()
+ aim.reset()
+ }}
+ onPointerDown={disarm}
+ onMouseLeave={() => {
+ arm()
+ }}
+ >
+ <Show when={peek()} keyed>
+ {(project) => <SidebarPanel project={project} merged={false} />}
</Show>
- </main>
+ </div>
+
+ <div
+ classList={{
+ "hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
+ "opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
+ "opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
+ "transition-[opacity,transform] motion-reduce:transition-none": true,
+ "duration-180 ease-out": peeked() && !layout.sidebar.opened(),
+ "duration-120 ease-in": !peeked() || layout.sidebar.opened(),
+ }}
+ style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
+ >
+ <div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
+ </div>
</div>
<Toast.Region />
</div>
diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx
index d813ef3e1..d3070e374 100644
--- a/packages/app/src/pages/layout/sidebar-shell.tsx
+++ b/packages/app/src/pages/layout/sidebar-shell.tsx
@@ -1,4 +1,4 @@
-import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
+import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import {
DragDropProvider,
DragDropSensors,
@@ -35,10 +35,22 @@ export const SidebarContent = (props: {
}): JSX.Element => {
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
const placement = () => (props.mobile ? "bottom" : "right")
+ let panel: HTMLDivElement | undefined
+
+ createEffect(() => {
+ const el = panel
+ if (!el) return
+ if (expanded()) {
+ el.removeAttribute("inert")
+ return
+ }
+ el.setAttribute("inert", "")
+ })
return (
- <div class="flex h-full w-full overflow-hidden">
+ <div class="flex h-full w-full min-w-0 overflow-hidden">
<div
+ data-component="sidebar-rail"
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
onMouseMove={props.aimMove}
>
@@ -100,7 +112,15 @@ export const SidebarContent = (props: {
</div>
</div>
- <Show when={expanded()}>{props.renderPanel()}</Show>
+ <div
+ ref={(el) => {
+ panel = el
+ }}
+ classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
+ aria-hidden={!expanded()}
+ >
+ {props.renderPanel()}
+ </div>
</div>
)
}
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
index 43d99cf89..f2fd3af2a 100644
--- a/packages/app/src/pages/layout/sidebar-workspace.tsx
+++ b/packages/app/src/pages/layout/sidebar-workspace.tsx
@@ -249,7 +249,7 @@ const WorkspaceSessionList = (props: {
loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
- <nav class="flex flex-col gap-1 px-2">
+ <nav class="flex flex-col gap-1 px-3">
<Show when={props.showNew()}>
<NewSessionItem
slug={props.slug()}
@@ -490,7 +490,7 @@ export const LocalWorkspace = (props: {
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
- <nav class="flex flex-col gap-1 px-2">
+ <nav class="flex flex-col gap-1 px-3">
<Show when={loading()}>
<SessionSkeleton />
</Show>
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx
index 66d4382c0..927461a2a 100644
--- a/packages/app/src/pages/session/session-side-panel.tsx
+++ b/packages/app/src/pages/session/session-side-panel.tsx
@@ -208,7 +208,7 @@ export function SessionSidePanel(props: {
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
- class="relative min-w-0 h-full border-l border-border-weak-base flex"
+ class="relative min-w-0 h-full border-l border-border-weaker-base flex"
classList={{
"flex-1": reviewOpen(),
"shrink-0": !reviewOpen(),
@@ -346,7 +346,7 @@ export function SessionSidePanel(props: {
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
- classList={{ "border-l border-border-weak-base": reviewOpen() }}
+ classList={{ "border-l border-border-weaker-base": reviewOpen() }}
>
<Tabs
variant="pill"
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
index cc4c17ee2..69c8aefcc 100644
--- a/packages/app/src/pages/session/terminal-panel.tsx
+++ b/packages/app/src/pages/session/terminal-panel.tsx
@@ -154,7 +154,7 @@ export function TerminalPanel() {
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
- <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
+ <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
@@ -187,7 +187,7 @@ export function TerminalPanel() {
onChange={(id) => terminal.open(id)}
class="!h-auto !flex-none"
>
- <Tabs.List class="h-10">
+ <Tabs.List class="h-10 border-b border-border-weaker-base">
<SortableProvider ids={ids()}>
<For each={ids()}>
{(id) => (