diff --git a/packages/app/src/index.css b/packages/app/src/index.css index a99034f5da9c..7b8435c04e3d 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -108,6 +108,45 @@ } } + .home-session-group-header::before { + content: ""; + position: absolute; + top: -12px; + left: 0; + width: 100%; + height: 12px; + background: var(--v2-background-bg-base); + } + + .home-session-group-header::after { + content: ""; + position: absolute; + top: 100%; + left: 0; + width: 100%; + height: 16px; + pointer-events: none; + background: linear-gradient( + 180deg, + var(--v2-background-bg-base) 0%, + color-mix(in srgb, var(--v2-background-bg-base) 92.0456%, transparent) 7.93%, + color-mix(in srgb, var(--v2-background-bg-base) 84.9947%, transparent) 14.14%, + color-mix(in srgb, var(--v2-background-bg-base) 78.6813%, transparent) 19%, + color-mix(in srgb, var(--v2-background-bg-base) 72.9394%, transparent) 22.85%, + color-mix(in srgb, var(--v2-background-bg-base) 67.6028%, transparent) 26.05%, + color-mix(in srgb, var(--v2-background-bg-base) 62.5055%, transparent) 28.95%, + color-mix(in srgb, var(--v2-background-bg-base) 57.4815%, transparent) 31.91%, + color-mix(in srgb, var(--v2-background-bg-base) 52.3647%, transparent) 35.27%, + color-mix(in srgb, var(--v2-background-bg-base) 46.989%, transparent) 39.4%, + color-mix(in srgb, var(--v2-background-bg-base) 41.1884%, transparent) 44.65%, + color-mix(in srgb, var(--v2-background-bg-base) 34.7969%, transparent) 51.36%, + color-mix(in srgb, var(--v2-background-bg-base) 27.6484%, transparent) 59.9%, + color-mix(in srgb, var(--v2-background-bg-base) 19.5767%, transparent) 70.62%, + color-mix(in srgb, var(--v2-background-bg-base) 10.416%, transparent) 83.87%, + transparent 100% + ); + } + [data-slot="titlebar-update-loader"] { display: block; flex-shrink: 0; diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 8817ef22b7c7..eae7e0145f56 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -1,5 +1,6 @@ import type { Session } from "@opencode-ai/sdk/v2/client" import { + type ComponentProps, createEffect, createMemo, createResource, @@ -68,6 +69,9 @@ import { archiveHomeSession } from "./home-session-archive" import { showToast } from "@/utils/toast" const HOME_SESSION_LIMIT = 64 +const HOME_SESSION_HEADER_STICKY_TOP = 12 +const HOME_SESSION_HEADER_TEXT_HEIGHT = 16 +const HOME_SESSION_HEADER_FADE_DISTANCE = 16 const SHOW_HOME_SESSION_ARCHIVE = false const HOME_ROW_LAYOUT = "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" @@ -133,6 +137,107 @@ function homeSessionSearchKey(record: HomeSessionRecord) { return `${pathKey(record.session.directory)}:${record.session.id}` } +function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) { + let viewport: HTMLDivElement | undefined + let content: HTMLDivElement | undefined + let positionFrame: number | undefined + let resizeObserver: ResizeObserver | undefined + const headerRefs = new Map() + const headerOffsets = new Map() + const [state, setState] = createStore({ + titleOpacity: {} as Partial>, + }) + + createEffect(() => { + const items = groups() + const ids = new Set(items.map((group) => group.id)) + headerRefs.forEach((_, id) => { + if (!ids.has(id)) headerRefs.delete(id) + }) + headerOffsets.forEach((_, id) => { + if (!ids.has(id)) headerOffsets.delete(id) + }) + if (items.length === 0) { + content = undefined + bindResizeObserver() + } + queuePositionUpdate() + }) + + onCleanup(() => { + if (positionFrame !== undefined) cancelAnimationFrame(positionFrame) + resizeObserver?.disconnect() + }) + + function setViewport(el: HTMLDivElement) { + viewport = el + bindResizeObserver() + queuePositionUpdate() + } + + function setContentRef(el: HTMLDivElement) { + content = el + bindResizeObserver() + queuePositionUpdate() + } + + function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) { + headerRefs.set(id, el) + queuePositionUpdate() + } + + function queuePositionUpdate() { + if (typeof requestAnimationFrame === "undefined") { + updatePositionCache() + return + } + if (positionFrame !== undefined) return + positionFrame = requestAnimationFrame(() => { + positionFrame = undefined + updatePositionCache() + }) + } + + function updatePositionCache() { + if (!viewport) return + groups().forEach((group) => { + const el = headerRefs.get(group.id) + if (!el) return + headerOffsets.set(group.id, el.offsetTop) + }) + update(viewport.scrollTop) + } + + function update(scrollTop: number) { + const items = groups() + items.forEach((group, index) => { + const nextOffset = items + .slice(index + 1) + .map((item) => headerOffsets.get(item.id)) + .find((offset) => offset !== undefined) + const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT + const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop + const opacity = + nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE)) + setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000) + }) + } + + function titleOpacity(id: HomeSessionGroup["id"]) { + return state.titleOpacity[id] ?? 1 + } + + function bindResizeObserver() { + resizeObserver?.disconnect() + if (typeof ResizeObserver === "undefined") return + resizeObserver = new ResizeObserver(() => queuePositionUpdate()) + if (viewport) resizeObserver.observe(viewport) + if (content) resizeObserver.observe(content) + } + + return { setViewport, setContentRef, setHeaderRef, update, titleOpacity } +} + export function NewHome() { const sync = useServerSync() const layout = useLayout() @@ -223,6 +328,7 @@ export function NewHome() { }) const searchOpen = createMemo(() => state.searchFocused && search().length > 0) const groups = createMemo(() => groupSessions(records(), language)) + const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups) const prefetched = new Set() createEffect(() => { @@ -434,7 +540,7 @@ export function NewHome() { />
- + sessionHeaderOpacity.update(event.currentTarget.scrollTop)} + > + 0 && newSessionProject()}> +
+ + {language.t("command.session.new")} + +
+
0} fallback={} > -
+
{(group, index) => ( -
+ <> sessionHeaderOpacity.setHeaderRef(group.id, el)} + elevated={index() === 0} /> -
+
{(record) => (
-
+ )}
@@ -957,7 +1085,7 @@ function HomeSessionSearch(props: { return (
-
+
void }) { - const language = useLanguage() +function HomeSessionGroupHeader(props: { + title: string + titleOpacity: number + ref: ComponentProps<"div">["ref"] + elevated?: boolean +}) { return ( -
- - - {(onNewSession) => ( - - {language.t("command.session.new")} - - )} - +
+
) }