mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): calm loading state for Home sessions (#57679)
This commit is contained in:
committed by
GitHub
Unverified
parent
3f4c0e7f38
commit
5798a4087a
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user