fix(app): make session navigation stable and fast (#33569)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Luke Parker 2026-06-24 17:48:54 +10:00 committed by GitHub
commit a4551a94b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1028 additions and 202 deletions

View file

@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
@ -32,7 +32,7 @@ import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
@ -53,87 +53,89 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome })))
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
const Session = lazy(() => import("@/pages/session"))
import Session from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
const SessionRoute = Object.assign(
() => {
const settings = useSettings()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const sdk = useSDK()
const server = useServer()
const tabs = useTabs()
const SessionRoute = () => {
const settings = useSettings()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const sdk = useSDK()
const server = useServer()
const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
return <Navigate href={sessionHref(server.key, params.id)} />
}
if (params.id && settings.general.newLayoutDesigns()) {
return <Navigate href={sessionHref(server.key, params.id)} />
}
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => {
if (!settings.general.newLayoutDesigns()) return
if (params.id || search.draftId) return
if (!tabs.ready() || !sdk().directory) return
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => {
if (!settings.general.newLayoutDesigns()) return
if (params.id || search.draftId) return
if (!tabs.ready() || !sdk().directory) return
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return (
<SessionProviders>
<Session />
</SessionProviders>
)
},
{ preload: Session.preload },
)
return (
<SessionProviders>
<Session />
</SessionProviders>
)
}
const TargetSessionRoute = Object.assign(
() => {
const params = useParams<{ serverKey: string; id: string }>()
const server = useServer()
const conn = createMemo(() => {
const key = requireServerKey(params.serverKey)
return server.list.find((item) => ServerConnection.key(item) === key)
})
const TargetSessionRoute = () => {
const params = useParams<{ serverKey: string; id: string }>()
const server = useServer()
const conn = createMemo(() => {
const key = requireServerKey(params.serverKey)
return server.list.find((item) => ServerConnection.key(item) === key)
})
return (
<Show when={`${params.serverKey}\0${params.id}`} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ResolvedTargetSessionRoute />
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
},
{ preload: Session.preload },
)
return (
<Show when={`${params.serverKey}\0${params.id}`} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ResolvedTargetSessionRoute />
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings()
const tabs = useTabs()
const global = useGlobal()
const serverSDK = useServerSDK()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const resolved = useQuery(() => ({
queryKey: [serverSDK().scope, "session-route", params.id] as const,
queryFn: async () => {
const session = (await serverSDK().client.session.get({ sessionID: params.id })).data!
const root = await rootSession(session, (sessionID) =>
serverSDK()
.client.session.get({ sessionID })
.then((result) => result.data!),
)
return { session, rootID: root.id }
const placement = createMemo(() => global.sessionPlacement.get(serverKey(), params.id))
const [resolved] = createResource(
() => {
if (placement()) return
return { id: params.id, sdk: serverSDK() }
},
}))
const directory = createMemo<string | undefined>((prev) => prev ?? resolved.data?.session.directory)
async ({ id, sdk }) => {
const session = (await sdk.client.session.get({ sessionID: id })).data!
const root = await rootSession(session, (sessionID) =>
sdk.client.session.get({ sessionID }).then((result) => result.data!),
)
return global.sessionPlacement.set({
server: serverKey(),
leafID: session.id,
rootID: root.id,
directory: session.directory,
})
},
)
const directory = createMemo(() => placement()?.directory ?? resolved()?.directory)
const targetDirectory = () => directory()!
createEffect(() => {
const current = resolved.data
const current = placement() ?? resolved()
if (!current) return
tabs.addSessionTab({
server: serverKey(),
@ -151,9 +153,7 @@ function ResolvedTargetSessionRoute() {
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<Show when={resolved.data && !resolved.isPlaceholderData}>
<TargetSessionPage />
</Show>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>

View file

@ -2,6 +2,7 @@ import {
createEffect,
createMemo,
createResource,
createRoot,
createSignal,
For,
Match,
@ -512,25 +513,64 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
}
const sdk = createMemo(() => {
const conn = server.list.find((s) => ServerConnection.key(s) === tab.server)
if (!conn) return null
const { sdk } = global.createServerCtx(conn)
return sdk
const serverCtx = createMemo(() => {
const conn = server.list.find((item) => ServerConnection.key(item) === tab.server)
return conn ? global.createServerCtx(conn) : undefined
})
const [session] = createResource(
const sdk = createMemo(() => serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => {
const placement = global.sessionPlacement.get(tab.server, tab.sessionId)
const ctx = serverCtx()
if (!placement || !ctx) return
return ctx.sync
.child(placement.directory, { bootstrap: false })[0]
.session.find((session) => session.id === tab.sessionId)
})
const [loadedSession] = createResource(
() => {
if (cachedSession()) return null
const id = tab.sessionId
const _sdk = sdk()
if (!_sdk) return null
return { id, sdk: _sdk }
const ctx = serverCtx()
return ctx ? { id, ctx } : null
},
({ id, sdk }) =>
sdk.client.session
({ id, ctx }) =>
ctx.sdk.client.session
.get({ sessionID: id })
.then((x) => x.data)
.then((x) => {
const session = x.data
if (!session) return
if (!session.parentID)
global.sessionPlacement.set({
server: tab.server,
leafID: session.id,
rootID: session.id,
directory: session.directory,
})
return session
})
.catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
let prefetched = false
createEffect(() => {
const ctx = serverCtx()
const sess = session()
if (!ctx || !sess || prefetched) return
prefetched = true
createRoot((dispose) => {
try {
void ctx.sync
.createDirSyncContext(sess.directory)
.session.sync(sess.id)
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
createEffect(() => {
if (tab.type !== "session") return

View file

@ -187,7 +187,7 @@ export const createDirSyncContext = (
return serverSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const initialMessagePageSize = 2
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()

View file

@ -8,11 +8,13 @@ import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
import { createSessionPlacementStore } from "@/utils/session-placement"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
init: () => {
const server = useServer()
const sessionPlacement = createSessionPlacementStore()
const serverHealth = useServerHealth(
() => server.list,
() => true,
@ -85,6 +87,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
},
},
sessionPlacement,
createServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn)
},

View file

@ -11,6 +11,7 @@ import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
import type { ServerConnection } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
import { useGlobal } from "@/context/global"
export function DirectoryDataProvider(
props: ParentProps<{
@ -23,6 +24,7 @@ export function DirectoryDataProvider(
const navigate = useNavigate()
const params = useParams()
const sync = useSync()
const global = useGlobal()
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => {
@ -52,7 +54,11 @@ export function DirectoryDataProvider(
<DataProvider
data={sync().data}
directory={directory()}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onNavigateToSession={(sessionID: string) => {
const server = props.server?.()
if (server && params.id) global.sessionPlacement.inherit(server, params.id, sessionID)
navigate(href(sessionID))
}}
onSessionHref={href}
>
<LocalProvider>{props.children}</LocalProvider>

View file

@ -3,6 +3,7 @@ import {
batch,
createEffect,
createMemo,
createRoot,
For,
Match,
on,
@ -58,6 +59,8 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health"
import { Persist, persisted } from "@/utils/persist"
import { useMarked } from "@opencode-ai/ui/context/marked"
import { preloadMarkdown } from "@opencode-ai/ui/markdown-cache"
const HOME_SESSION_LIMIT = 64
const HOME_ROW_LAYOUT =
@ -134,9 +137,10 @@ export function NewHome() {
const server = useServer()
const language = useLanguage()
const global = useGlobal()
const tabs = useTabs()
const command = useCommand()
const notification = useNotification()
const tabs = useTabs()
const marked = useMarked()
let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({
search: "",
@ -199,6 +203,41 @@ export function NewHome() {
})
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const groups = createMemo(() => groupSessions(records(), language))
const prefetched = new Set<string>()
createEffect(() => {
const ctx = focusedServerCtx()
if (!ctx) return
records()
.slice(0, 2)
.forEach((record) => {
const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}`
if (prefetched.has(key)) return
prefetched.add(key)
createRoot((dispose) => {
try {
const directory = ctx.sync.createDirSyncContext(record.session.directory)
void directory.session
.sync(record.session.id)
.then(() => {
const store = ctx.sync.child(record.session.directory)[0]
return Promise.all(
(store.message[record.session.id] ?? []).flatMap((message) =>
(store.part[message.id] ?? []).flatMap((part) => {
if (part.type !== "text" || !part.text) return []
return preloadMarkdown(part.text, part.id, marked)
}),
),
)
})
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
})
function setSelection(next: HomeProjectSelection) {
batch(() => {
@ -304,6 +343,12 @@ export function NewHome() {
if (!conn) return
const directory = project?.worktree ?? session.directory
const ctx = global.createServerCtx(conn)
global.sessionPlacement.set({
server: ServerConnection.key(conn),
leafID: session.id,
rootID: session.id,
directory: session.directory,
})
ctx.projects.open(directory)
ctx.projects.touch(directory)
startTransition(() => {

View file

@ -287,7 +287,7 @@ export default function Page() {
})
}
return key
}, sessionKey())
})
let reviewFrame: number | undefined
let todoFrame: number | undefined
@ -693,6 +693,7 @@ export default function Page() {
}
createEffect(() => {
if (!sync().project) return
const list = changesOptions()
if (list.includes(store.changes)) return
const next = list[0]
@ -1128,13 +1129,34 @@ export default function Page() {
let captureHistoryAnchor = () => {}
let restoreHistoryAnchor = (_done: boolean) => {}
const loadOlder = () =>
timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
let historyRequest = false
let historyContinuationFrame: number | undefined
const loadOlder = async () => {
if (historyRequest || historyLoading()) return
historyRequest = true
const before = timeline.messages().length
try {
await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
} finally {
historyRequest = false
}
if (timeline.messages().length <= before) return
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
historyContinuationFrame = requestAnimationFrame(() => {
historyContinuationFrame = undefined
onHistoryScroll()
})
}
const onHistoryScroll = () => {
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return
if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return
void loadOlder()
}
onCleanup(() => {
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
})
fill = () => {
if (fillFrame !== undefined) return

View file

@ -15,37 +15,29 @@ describe("timeline model", () => {
expect(selectVisibleUserMessages(users)).toBe(users)
})
test("loads pages until a visible user turn is added", async () => {
let loaded = 10
let visible = 2
test("loads exactly one opaque cursor page", async () => {
let calls = 0
const anchors: Array<string | boolean> = []
await loadOlderTimeline({
sessionID: () => "ses_test",
loaded: () => loaded,
visible: () => visible,
more: () => true,
loading: () => false,
loadMore: async () => {
calls += 1
loaded += 3
if (calls === 2) visible += 1
},
before: () => anchors.push("before"),
after: (done) => anchors.push("after", done),
})
expect(calls).toBe(2)
expect(anchors).toEqual(["before", "after", false, "after", true])
expect(calls).toBe(1)
expect(anchors).toEqual(["before", "after", true])
})
test("stops when a page adds no raw messages", async () => {
let calls = 0
await loadOlderTimeline({
sessionID: () => "ses_test",
loaded: () => 10,
visible: () => 2,
more: () => true,
loading: () => false,
loadMore: async () => {
@ -62,8 +54,6 @@ describe("timeline model", () => {
await loadOlderTimeline({
sessionID: () => sessionID,
loaded: () => 10,
visible: () => 2,
more: () => true,
loading: () => false,
loadMore: async () => {
@ -83,8 +73,6 @@ describe("timeline model", () => {
await expect(
loadOlderTimeline({
sessionID: () => "ses_test",
loaded: () => 10,
visible: () => 2,
more: () => true,
loading: () => false,
loadMore: async () => {

View file

@ -74,8 +74,6 @@ export function createTimelineModel(input: {
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
return loadOlderTimeline({
sessionID: input.sessionID,
loaded: () => messages().length,
visible: () => visibleUserMessages().length,
more,
loading,
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
@ -115,8 +113,6 @@ export function selectVisibleUserMessages(messages: UserMessage[], revertMessage
export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined>
loaded: Accessor<number>
visible: Accessor<number>
more: Accessor<boolean>
loading: Accessor<boolean>
loadMore: (sessionID: string) => Promise<void>
@ -126,23 +122,11 @@ export async function loadOlderTimeline(input: {
const id = input.sessionID()
if (!id || !input.more() || input.loading()) return
// A history page may contain only assistant messages or user turns hidden by a revert boundary.
const beforeVisible = input.visible()
let loaded = input.loaded()
input.before?.()
while (true) {
await input.loadMore(id).catch((error) => {
if (input.sessionID() === id) input.after?.(true)
throw error
})
if (input.sessionID() !== id) return
const nextLoaded = input.loaded()
const growth = input.visible() - beforeVisible
const raw = nextLoaded - loaded
loaded = nextLoaded
const done = growth > 0 || raw <= 0 || !input.more()
input.after?.(done)
if (done) return
}
await input.loadMore(id).catch((error) => {
if (input.sessionID() === id) input.after?.(true)
throw error
})
if (input.sessionID() !== id) return
input.after?.(true)
}

View file

@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { createSessionPlacementStore } from "./session-placement"
describe("session placement", () => {
const local = ServerConnection.Key.make("http://localhost:4096")
const remote = ServerConnection.Key.make("https://example.com")
test("aliases a leaf and root without crossing servers", () => {
const store = createSessionPlacementStore()
store.set({ server: local, leafID: "child", rootID: "root", directory: "/repo" })
store.set({ server: remote, leafID: "child", rootID: "other", directory: "/remote" })
expect(store.get(local, "child")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.get(local, "root")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.get(remote, "child")).toEqual({ rootID: "other", directory: "/remote" })
})
test("inherits known placement for in-app child navigation", () => {
const store = createSessionPlacementStore()
store.set({ server: local, leafID: "parent", rootID: "root", directory: "/repo" })
expect(store.inherit(local, "parent", "child")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.get(local, "child")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.inherit(local, "missing", "unknown")).toBeUndefined()
})
test("bounds retained placement aliases", () => {
const store = createSessionPlacementStore()
for (let index = 0; index < 300; index++) {
store.set({ server: local, leafID: `leaf-${index}`, rootID: `root-${index}`, directory: `/repo/${index}` })
}
expect(store.size()).toBeLessThanOrEqual(256)
expect(store.get(local, "leaf-0")).toBeUndefined()
expect(store.get(local, "leaf-299")).toEqual({ rootID: "root-299", directory: "/repo/299" })
})
})

View file

@ -0,0 +1,41 @@
import { ServerConnection } from "@/context/server"
export type SessionPlacement = {
rootID: string
directory: string
}
export function createSessionPlacementStore() {
const placements = new Map<string, SessionPlacement>()
const limit = 256
const key = (server: ServerConnection.Key, sessionID: string) => `${server}\0${sessionID}`
const write = (id: string, placement: SessionPlacement) => {
placements.delete(id)
placements.set(id, placement)
while (placements.size > limit) placements.delete(placements.keys().next().value!)
}
return {
get(server: ServerConnection.Key, sessionID: string) {
const id = key(server, sessionID)
const placement = placements.get(id)
if (placement) write(id, placement)
return placement
},
set(input: SessionPlacement & { server: ServerConnection.Key; leafID: string }) {
const placement = { rootID: input.rootID, directory: input.directory }
write(key(input.server, input.leafID), placement)
write(key(input.server, input.rootID), placement)
return placement
},
inherit(server: ServerConnection.Key, sourceID: string, leafID: string) {
const placement = placements.get(key(server, sourceID))
if (!placement) return
write(key(server, leafID), placement)
return placement
},
size() {
return placements.size
},
}
}