From 5798a4087a184da50ab8cb94ed44310d25dbf21b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 21 Oct 2026 09:05:17 -0700 Subject: [PATCH] fix(app): calm loading state for Home sessions (#57679) --- packages/app/src/home/sessions/controller.tsx | 2 + packages/app/src/home/sessions/view.css | 51 +++++++++++++ packages/app/src/home/sessions/view.tsx | 75 ++++++++++++++----- packages/app/src/runtime/server/data.test.ts | 10 ++- packages/app/src/runtime/server/data.ts | 6 +- .../src/runtime/server/request-queue.test.ts | 33 ++++++++ .../app/src/runtime/server/request-queue.ts | 13 +++- 7 files changed, 166 insertions(+), 24 deletions(-) diff --git a/packages/app/src/home/sessions/controller.tsx b/packages/app/src/home/sessions/controller.tsx index 8ef5de1f93e..13b86703be4 100644 --- a/packages/app/src/home/sessions/controller.tsx +++ b/packages/app/src/home/sessions/controller.tsx @@ -70,6 +70,8 @@ export function createHomeSessionsController(home: HomeController) { : skipToken, retry: false, staleTime: 30_000, + // Returning to Home shows the last list and refreshes it, instead of loading again once the cache expires. + gcTime: Infinity, refetchOnMount: true, refetchOnReconnect: true, select: selectSessions, diff --git a/packages/app/src/home/sessions/view.css b/packages/app/src/home/sessions/view.css index 39b9a13dde3..b7e327ee4e6 100644 --- a/packages/app/src/home/sessions/view.css +++ b/packages/app/src/home/sessions/view.css @@ -24,3 +24,54 @@ line-height: var(--line-height-compact); } } + +/* The highlight sweeps in reading direction. */ +[data-component="home-session-skeleton"] { + opacity: 0; + transition: opacity 150ms cubic-bezier(0.4, 0, 1, 1); + mask-image: linear-gradient(90deg, rgb(0 0 0 / 0.55) 35%, #000 50%, rgb(0 0 0 / 0.55) 65%); + mask-size: 300% 100%; + animation: home-session-skeleton-sweep 1.8s cubic-bezier(0.45, 0, 0.55, 1) infinite; +} + +[data-component="home-session-skeleton"][data-visible] { + opacity: 1; + transition: opacity 200ms cubic-bezier(0.2, 0.8, 0.2, 1); + + @starting-style { + opacity: 0; + } +} + +[data-component="home-session-skeleton"]:dir(rtl) { + animation-direction: reverse; +} + +[data-slot="home-sessions-list"][data-reveal] > :not([data-component="home-session-skeleton"]) { + animation: home-sessions-reveal 200ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +@keyframes home-session-skeleton-sweep { + from { + mask-position: 100% 0; + } + + to { + mask-position: 0% 0; + } +} + +@keyframes home-sessions-reveal { + from { + opacity: 0; + } +} + +:root[data-motion="reduce"] { + [data-component="home-session-skeleton"], + [data-slot="home-sessions-list"][data-reveal] > :not([data-component="home-session-skeleton"]) { + animation: none; + mask-image: none; + transition: none; + } +} diff --git a/packages/app/src/home/sessions/view.tsx b/packages/app/src/home/sessions/view.tsx index f83940f13fd..adb7f0a06c4 100644 --- a/packages/app/src/home/sessions/view.tsx +++ b/packages/app/src/home/sessions/view.tsx @@ -1,6 +1,6 @@ import type { SessionInfo } from "@opencode/client/promise" import { Key } from "@solid-primitives/keyed" -import { createMemo, For, Index, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, Index, onCleanup, Show } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" import { InlineInput } from "@opencode/ui/inline-input" import { Spinner } from "@opencode/ui/spinner" @@ -10,6 +10,7 @@ import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" import { Menu } from "@opencode/ui/menu" import { Tooltip } from "@opencode/ui/tooltip" +import { prefersReducedMotion } from "@opencode/ui/motion" import { useLanguage } from "@/runtime/i18n/language" import { ServerConnection } from "@/runtime/server/registry" import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar" @@ -33,6 +34,14 @@ const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results" const HOME_SESSION_LONG_PRESS_MS = 500 +const HOME_SESSION_PLACEHOLDER_DELAY_MS = 250 + +// Matches the list's reveal animation in view.css. +const HOME_SESSION_REVEAL_MS = 200 + +// Title widths for the placeholder rows, as a share of the row's label area. +const HOME_SESSION_PLACEHOLDER_WIDTHS = [56, 38, 66, 46, 30, 52] + // Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session // tab in the background without navigating, matching browser conventions. function isBackgroundOpen(event: MouseEvent) { @@ -100,6 +109,17 @@ export function HomeSessionsView(props: HomeSessionsViewProps) { const [rowUI, setRowUI] = createStore({ menu: undefined, editor: undefined }) const [hover, setHover] = createStore<{ sessionID: string | undefined }>({ sessionID: undefined }) const showLocations = createAltHold(props.desktop, props.onRevealLocations) + // A list that arrives within the delay paints directly; slower loads show placeholder rows until it does, which stay + // mounted while the list fades in over them. + const [placeholder, setPlaceholder] = createSignal(false) + + createEffect(() => { + const timer = props.loading + ? setTimeout(() => setPlaceholder(true), HOME_SESSION_PLACEHOLDER_DELAY_MS) + : setTimeout(() => setPlaceholder(false), prefersReducedMotion() ? 0 : HOME_SESSION_REVEAL_MS) + + onCleanup(() => clearTimeout(timer)) + }) return (
-
- - -
- } - > +
+ + + + 0} fallback={ @@ -915,14 +940,30 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT ) } -function HomeSessionSkeleton(props: { label: string }) { +function HomeSessionSkeleton(props: { visible: boolean; showProjectName: boolean; desktop: boolean }) { return ( -
-
- -
-