fix(app): calm loading state for Home sessions (#57679)

This commit is contained in:
Kit Langton
2026-10-21 09:05:17 -07:00
committed by GitHub
Unverified
parent 3f4c0e7f38
commit 5798a4087a
7 changed files with 166 additions and 24 deletions
@@ -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,
+51
View File
@@ -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;
}
}
+58 -17
View File
@@ -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<HomeSessionRowUI>({ 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 (
<section
@@ -139,15 +159,20 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
class="relative ml-auto h-[calc(100cqh-68px)] w-3 md:h-[calc(100cqh-84px)] lg:h-[calc(100cqh-108px)]"
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-64px)] md:min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!props.loading}
fallback={
<div class="pt-1 md:pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
</div>
}
>
<div
data-slot="home-sessions-list"
data-reveal={placeholder() || undefined}
class="relative -mr-3 min-h-[calc(100cqh-64px)] md:min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]"
aria-busy={props.loading}
>
<Show when={placeholder()}>
<HomeSessionSkeleton
visible={props.loading}
showProjectName={props.showProjectName}
desktop={props.desktop}
/>
</Show>
<Show when={!props.loading}>
<Show
when={props.groups.length > 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 (
<div class="flex min-w-0 flex-col gap-4">
<div class="flex h-7 min-w-0 items-center justify-between ps-1.5 pe-4 md:ps-4">
<div class={HOME_SECTION_LABEL}>{props.label}</div>
</div>
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
<For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
<div
data-component="home-session-skeleton"
data-visible={props.visible || undefined}
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-0 pt-1 pr-3 md:pt-3"
>
<div class="h-7" />
<div class="flex min-w-0 flex-col gap-px pt-2 md:pt-4">
<For each={HOME_SESSION_PLACEHOLDER_WIDTHS}>
{(width) => (
<div
class="flex h-10 min-w-0 items-center ps-1.5 pe-3 md:ps-3 md:pe-10"
classList={{ "gap-1.5": props.desktop, "gap-2": !props.desktop, "max-md:h-12": props.showProjectName }}
>
<span class="size-4 shrink-0 rounded-[4px] bg-v2-background-bg-layer-02" />
<span class="h-2 rounded-full bg-v2-background-bg-layer-02" style={{ width: `${width}%` }} />
<Show when={props.showProjectName}>
<span class="h-2 w-16 shrink-0 rounded-full bg-v2-background-bg-layer-01" />
</Show>
</div>
)}
</For>
</div>
</div>
)
+8 -2
View File
@@ -4,7 +4,7 @@ import { createSessionMutations } from "./data"
const session = { id: "ses_test" } as SessionInfo
test("keeps a successful removal applied until its event arrives", async () => {
test("keeps a removed session hidden after its deletion event, for lists cached before it", async () => {
const release = Promise.withResolvers<void>()
const mutation = createSessionMutations(async () => release.promise)
@@ -15,7 +15,13 @@ test("keeps a successful removal applied until its event arrives", async () => {
expect(mutation.apply([session])).toEqual([])
mutation.deleted(session.id)
expect(mutation.apply([session])).toEqual([session])
expect(mutation.apply([session])).toEqual([])
})
test("hides a session another client deleted", () => {
const mutation = createSessionMutations(async () => {})
mutation.deleted(session.id)
expect(mutation.apply([session])).toEqual([])
})
test("rolls back a failed removal", async () => {
+5 -1
View File
@@ -47,8 +47,12 @@ export function createSessionMutations(remove: (sessionID: string) => Promise<vo
throw error
})
},
// Keep the deleted session hidden: cached lists such as the Home index still hold it until they refetch.
deleted(sessionID: string) {
setStore("session", (current) => current.filter((mutation) => mutation.sessionID !== sessionID))
setStore("session", (current) => [
...current.filter((mutation) => mutation.sessionID !== sessionID),
{ id: uuid(), type: "remove", sessionID },
])
},
}
}
@@ -102,6 +102,39 @@ describe("createRequestQueue", () => {
expect(input.queue.inflight()).toBe(0)
})
test("writes, then session lists, take the next free slot ahead of queued reads, each tier in order", async () => {
const input = setup({ limit: 1 })
const requests: [string, string][] = [
["GET", "/api/a"],
["GET", "/api/b"],
["GET", "/api/session?limit=1"],
["POST", "/api/session/ses_1/message"],
["GET", "/api/session?limit=2"],
["DELETE", "/api/session/ses_2"],
["GET", "/api/session/ses_1"],
]
const responses = requests.map(([method, path]) => input.queue.fetch(`http://server${path}`, { method }))
await input.settle()
for (const _ of requests) {
input.pending.at(-1)?.resolve()
await input.settle()
}
expect(input.pending.map((item) => new URL(item.url).pathname + new URL(item.url).search)).toEqual([
"/api/a",
"/api/session/ses_1/message",
"/api/session/ses_2",
"/api/session?limit=1",
"/api/session?limit=2",
"/api/b",
"/api/session/ses_1",
])
await Promise.all(responses)
})
test("never counts the event stream against the budget", async () => {
const input = setup({ limit: 1 })
void input.queue.fetch("http://server/api/session")
@@ -1,4 +1,4 @@
type Entry = { method: string; url: string; at: number; slow: boolean }
type Entry = { method: string; url: string; at: number; slow: boolean; priority: number }
// Chromium allows six connections per origin. The event stream holds one for the life of the
// connection and health probes use their own fetch, so the app's API calls stay below that or
@@ -86,7 +86,7 @@ export function createRequestQueue(input: {
return [...inflight].filter((item) => item.slow).length < slowLimit
}
// FIFO, except a slow request waits its turn behind faster ones while the slow slots are full.
// FIFO within each priority tier, except a slow request waits its turn behind faster ones while the slow slots are full.
const release = (entry: Entry) => {
inflight.delete(entry)
const index = waiting.findIndex((item) => canStart(item.entry))
@@ -111,7 +111,8 @@ export function createRequestQueue(input: {
resolve()
}
waiting.push({ entry, start })
const position = waiting.findIndex((item) => item.entry.priority < entry.priority)
waiting.splice(position === -1 ? waiting.length : position, 0, { entry, start })
watcher ??= setTimeout(watch, stallMs)
})
}
@@ -123,7 +124,11 @@ export function createRequestQueue(input: {
// The event stream is long-lived; never count it against the request budget.
if (pathname === "/api/event") return base(request)
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
// Writes (prompt sends, replies, edits) are the user's own actions and never wait behind reads. Listing sessions is
// one indexed database read that Home shows nothing without, so it goes ahead of queued location reads, which can
// each wait seconds on a location the server is still loading.
const priority = request.method !== "GET" ? 2 : pathname === "/api/session" ? 1 : 0
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname), priority }
const queued = acquire(entry)
if (queued) await queued