diff options
| author | David Hill <[email protected]> | 2026-03-06 22:33:34 +0000 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-06 16:33:34 -0600 |
| commit | b0bc3d87f59fb28340fc4c047131919031890898 (patch) | |
| tree | 35c56cbdb069fb720de642d7a4cc0bd6332e3747 /packages/app/src | |
| parent | a2634337b84643c08df5337243e8f82399c85615 (diff) | |
| download | opencode-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.tsx | 200 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-shell.tsx | 26 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-workspace.tsx | 4 | ||||
| -rw-r--r-- | packages/app/src/pages/session/session-side-panel.tsx | 4 | ||||
| -rw-r--r-- | packages/app/src/pages/session/terminal-panel.tsx | 4 |
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) => ( |
