summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-07 08:41:16 -0600
committerAdam <[email protected]>2026-01-08 17:48:15 -0600
commit374275eeb691006fa8f422d6267aa694ab38a992 (patch)
tree9159db2dabb0ba2e10e82b7efb3cd4fbabf024d0
parentfaa848cfb13df4e435dc236fe895032ea6f26dde (diff)
downloadopencode-374275eeb691006fa8f422d6267aa694ab38a992.tar.gz
opencode-374275eeb691006fa8f422d6267aa694ab38a992.zip
feat(app): chunk message loading, lazy load diffs
-rw-r--r--packages/app/src/context/sync.tsx200
-rw-r--r--packages/app/src/pages/layout.tsx17
-rw-r--r--packages/app/src/pages/session.tsx243
-rw-r--r--packages/app/src/utils/perf.ts135
4 files changed, 471 insertions, 124 deletions
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index a237871f9..7138c4ab4 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -1,5 +1,5 @@
import { batch, createMemo } from "solid-js"
-import { produce, reconcile } from "solid-js/store"
+import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -14,6 +14,60 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const sdk = useSDK()
const [store, setStore] = globalSync.child(sdk.directory)
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
+ const chunk = 200
+ const inflight = new Map<string, Promise<void>>()
+ const inflightDiff = new Map<string, Promise<void>>()
+ const inflightTodo = new Map<string, Promise<void>>()
+ const [meta, setMeta] = createStore({
+ limit: {} as Record<string, number>,
+ complete: {} as Record<string, boolean>,
+ loading: {} as Record<string, boolean>,
+ })
+
+ const getSession = (sessionID: string) => {
+ const match = Binary.search(store.session, sessionID, (s) => s.id)
+ if (match.found) return store.session[match.index]
+ return undefined
+ }
+
+ const loadMessages = async (sessionID: string, limit: number) => {
+ if (meta.loading[sessionID]) return
+
+ setMeta("loading", sessionID, true)
+ await retry(() => sdk.client.session.messages({ sessionID, limit }))
+ .then((messages) => {
+ const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
+ const next = items
+ .map((x) => x.info)
+ .filter((m) => !!m?.id)
+ .slice()
+ .sort((a, b) => a.id.localeCompare(b.id))
+
+ batch(() => {
+ setStore("message", sessionID, reconcile(next, { key: "id" }))
+
+ for (const message of items) {
+ setStore(
+ "part",
+ message.info.id,
+ reconcile(
+ message.parts
+ .filter((p) => !!p?.id)
+ .slice()
+ .sort((a, b) => a.id.localeCompare(b.id)),
+ { key: "id" },
+ ),
+ )
+ }
+
+ setMeta("limit", sessionID, limit)
+ setMeta("complete", sessionID, next.length < limit)
+ })
+ })
+ .finally(() => {
+ setMeta("loading", sessionID, false)
+ })
+ }
return {
data: store,
@@ -30,11 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined
},
session: {
- get(sessionID: string) {
- const match = Binary.search(store.session, sessionID, (s) => s.id)
- if (match.found) return store.session[match.index]
- return undefined
- },
+ get: getSession,
addOptimisticMessage(input: {
sessionID: string
messageID: string
@@ -66,58 +116,96 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}),
)
},
- async sync(sessionID: string, _isRetry = false) {
- const [session, messages, todo, diff] = await Promise.all([
- retry(() => sdk.client.session.get({ sessionID })),
- retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
- retry(() => sdk.client.session.todo({ sessionID })),
- retry(() => sdk.client.session.diff({ sessionID })),
- ])
+ async sync(sessionID: string) {
+ const hasSession = getSession(sessionID) !== undefined
+ const hasMessages = store.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
+ if (hasSession && hasMessages) return
- batch(() => {
- setStore(
- "session",
- produce((draft) => {
- const match = Binary.search(draft, sessionID, (s) => s.id)
- if (match.found) {
- draft[match.index] = session.data!
- return
- }
- draft.splice(match.index, 0, session.data!)
- }),
- )
-
- setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
- setStore(
- "message",
- sessionID,
- reconcile(
- (messages.data ?? [])
- .map((x) => x.info)
- .filter((m) => !!m?.id)
- .slice()
- .sort((a, b) => a.id.localeCompare(b.id)),
- { key: "id" },
- ),
- )
-
- for (const message of messages.data ?? []) {
- if (!message?.info?.id) continue
- setStore(
- "part",
- message.info.id,
- reconcile(
- message.parts
- .filter((p) => !!p?.id)
- .slice()
- .sort((a, b) => a.id.localeCompare(b.id)),
- { key: "id" },
- ),
- )
- }
+ const pending = inflight.get(sessionID)
+ if (pending) return pending
- setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
- })
+ const limit = meta.limit[sessionID] ?? chunk
+
+ const sessionReq = hasSession
+ ? Promise.resolve()
+ : retry(() => sdk.client.session.get({ sessionID })).then((session) => {
+ const data = session.data
+ if (!data) return
+ setStore(
+ "session",
+ produce((draft) => {
+ const match = Binary.search(draft, sessionID, (s) => s.id)
+ if (match.found) {
+ draft[match.index] = data
+ return
+ }
+ draft.splice(match.index, 0, data)
+ }),
+ )
+ })
+
+ const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit)
+
+ const promise = Promise.all([sessionReq, messagesReq])
+ .then(() => {})
+ .finally(() => {
+ inflight.delete(sessionID)
+ })
+
+ inflight.set(sessionID, promise)
+ return promise
+ },
+ async diff(sessionID: string) {
+ if (store.session_diff[sessionID] !== undefined) return
+
+ const pending = inflightDiff.get(sessionID)
+ if (pending) return pending
+
+ const promise = retry(() => sdk.client.session.diff({ sessionID }))
+ .then((diff) => {
+ setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
+ })
+ .finally(() => {
+ inflightDiff.delete(sessionID)
+ })
+
+ inflightDiff.set(sessionID, promise)
+ return promise
+ },
+ async todo(sessionID: string) {
+ if (store.todo[sessionID] !== undefined) return
+
+ const pending = inflightTodo.get(sessionID)
+ if (pending) return pending
+
+ const promise = retry(() => sdk.client.session.todo({ sessionID }))
+ .then((todo) => {
+ setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
+ })
+ .finally(() => {
+ inflightTodo.delete(sessionID)
+ })
+
+ inflightTodo.set(sessionID, promise)
+ return promise
+ },
+ history: {
+ more(sessionID: string) {
+ if (store.message[sessionID] === undefined) return false
+ if (meta.limit[sessionID] === undefined) return false
+ if (meta.complete[sessionID]) return false
+ return true
+ },
+ loading(sessionID: string) {
+ return meta.loading[sessionID] ?? false
+ },
+ async loadMore(sessionID: string, count = chunk) {
+ if (meta.loading[sessionID]) return
+ if (meta.complete[sessionID]) return
+
+ const current = meta.limit[sessionID] ?? chunk
+ await loadMessages(sessionID, current + count)
+ },
},
fetch: async (count = 10) => {
setStore("limit", (x) => x + count)
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index a0f364d9a..50553795c 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -55,6 +55,7 @@ import { DialogEditProject } from "@/components/dialog-edit-project"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
+import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { useServer } from "@/context/server"
@@ -309,6 +310,14 @@ export default function Layout(props: ParentProps) {
if (targetIndex >= 0 && targetIndex < sessions.length) {
const session = sessions[targetIndex]
+ if (import.meta.env.DEV) {
+ navStart({
+ dir: base64Encode(session.directory),
+ from: params.id,
+ to: session.id,
+ trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
+ })
+ }
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id))
return
@@ -325,6 +334,14 @@ export default function Layout(props: ParentProps) {
}
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
+ if (import.meta.env.DEV) {
+ navStart({
+ dir: base64Encode(targetSession.directory),
+ from: params.id,
+ to: targetSession.id,
+ trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
+ })
+ }
navigateToSession(targetSession)
queueMicrotask(() => scrollToSession(targetSession.id))
}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index d3d8ef387..4a40ae7d2 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1,4 +1,4 @@
-import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
@@ -8,6 +8,7 @@ import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input"
import { SessionContextUsage } from "@/components/session-context-usage"
import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
@@ -49,6 +50,7 @@ import {
NewSessionView,
} from "@/components/session"
import { usePlatform } from "@/context/platform"
+import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same"
type DiffStyle = "unified" | "split"
@@ -162,6 +164,46 @@ export default function Page() {
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
+ if (import.meta.env.DEV) {
+ createEffect(
+ on(
+ () => [params.dir, params.id] as const,
+ ([dir, id], prev) => {
+ if (!id) return
+ navParams({ dir, from: prev?.[1], to: id })
+ },
+ ),
+ )
+
+ createEffect(() => {
+ const id = params.id
+ if (!id) return
+ if (!prompt.ready()) return
+ navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
+ })
+
+ createEffect(() => {
+ const id = params.id
+ if (!id) return
+ if (!terminal.ready()) return
+ navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
+ })
+
+ createEffect(() => {
+ const id = params.id
+ if (!id) return
+ if (!file.ready()) return
+ navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
+ })
+
+ createEffect(() => {
+ const id = params.id
+ if (!id) return
+ if (sync.data.message[id] === undefined) return
+ navMark({ dir: params.dir, to: id, name: "session:data-ready" })
+ })
+ }
+
const isDesktop = createMediaQuery("(min-width: 768px)")
function normalizeTab(tab: string) {
@@ -216,6 +258,8 @@ export default function Page() {
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+ const reviewCount = createMemo(() => info()?.summary?.files ?? 0)
+ const hasReview = createMemo(() => reviewCount() > 0)
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const messagesReady = createMemo(() => {
@@ -223,6 +267,16 @@ export default function Page() {
if (!id) return true
return sync.data.message[id] !== undefined
})
+ const historyMore = createMemo(() => {
+ const id = params.id
+ if (!id) return false
+ return sync.session.history.more(id)
+ })
+ const historyLoading = createMemo(() => {
+ const id = params.id
+ if (!id) return false
+ return sync.session.history.loading(id)
+ })
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
const visibleUserMessages = createMemo(() => {
@@ -290,6 +344,12 @@ export default function Page() {
}
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
+ const diffsReady = createMemo(() => {
+ const id = params.id
+ if (!id) return true
+ if (!hasReview()) return true
+ return sync.data.session_diff[id] !== undefined
+ })
const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement
@@ -643,12 +703,10 @@ export default function Page() {
.filter((tab) => tab !== "context"),
)
- const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review")
- const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review")
+ const reviewTab = createMemo(() => hasReview() || tabs().active() === "review")
+ const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review")
- const showTabs = createMemo(
- () => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
- )
+ const showTabs = createMemo(() => layout.review.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()))
const activeTab = createMemo(() => {
const active = tabs().active()
@@ -664,10 +722,22 @@ export default function Page() {
createEffect(() => {
if (!layout.ready()) return
if (tabs().active()) return
- if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return
+ if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return
tabs().setActive(activeTab())
})
+ createEffect(() => {
+ const id = params.id
+ if (!id) return
+ if (!hasReview()) return
+
+ const wants = isDesktop() ? layout.review.opened() && activeTab() === "review" : store.mobileTab === "review"
+ if (!wants) return
+ if (diffsReady()) return
+
+ sync.session.diff(id)
+ })
+
const isWorking = createMemo(() => status().type !== "idle")
const autoScroll = createAutoScroll({
working: isWorking,
@@ -779,7 +849,7 @@ export default function Page() {
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
{/* Mobile tab bar - only shown on mobile when there are diffs */}
- <Show when={!isDesktop() && diffs().length > 0}>
+ <Show when={!isDesktop() && hasReview()}>
<Tabs class="h-auto">
<Tabs.List>
<Tabs.Trigger
@@ -796,7 +866,7 @@ export default function Page() {
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "review")}
>
- {diffs().length} Files Changed
+ {reviewCount()} Files Changed
</Tabs.Trigger>
</Tabs.List>
</Tabs>
@@ -821,21 +891,26 @@ export default function Page() {
when={!mobileReview()}
fallback={
<div class="relative h-full overflow-hidden">
- <SessionReviewTab
- diffs={diffs}
- view={view}
- diffStyle="unified"
- onViewFile={(path) => {
- const value = file.tab(path)
- tabs().open(value)
- file.load(path)
- }}
- classes={{
- root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
- header: "px-4",
- container: "px-4",
- }}
- />
+ <Show
+ when={diffsReady()}
+ fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
+ >
+ <SessionReviewTab
+ diffs={diffs}
+ view={view}
+ diffStyle="unified"
+ onViewFile={(path) => {
+ const value = file.tab(path)
+ tabs().open(value)
+ file.load(path)
+ }}
+ classes={{
+ root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
+ header: "px-4",
+ container: "px-4",
+ }}
+ />
+ </Show>
</div>
}
>
@@ -868,42 +943,69 @@ export default function Page() {
"mt-0": showTabs(),
}}
>
- <For each={visibleUserMessages()}>
- {(message) => (
- <div
- id={anchor(message.id)}
- data-message-id={message.id}
- classList={{
- "min-w-0 w-full max-w-full": true,
- "last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
- platform.platform !== "desktop",
- "last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
- platform.platform === "desktop",
+ <Show when={historyMore()}>
+ <div class="w-full flex justify-center">
+ <Button
+ variant="ghost"
+ size="large"
+ class="text-12-medium opacity-50"
+ disabled={historyLoading()}
+ onClick={() => {
+ const id = params.id
+ if (!id) return
+ sync.session.history.loadMore(id)
}}
>
- <SessionTurn
- sessionID={params.id!}
- messageID={message.id}
- lastUserMessageID={lastUserMessage()?.id}
- stepsExpanded={store.expanded[message.id] ?? false}
- onStepsExpandedToggle={() =>
- setStore("expanded", message.id, (open: boolean | undefined) => !open)
- }
- classes={{
- root: "min-w-0 w-full relative",
- content:
- "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
- container:
- "px-4 md:px-6 " +
- (!showTabs()
- ? "md:max-w-200 md:mx-auto"
- : visibleUserMessages().length > 1
- ? "md:pr-6 md:pl-18"
- : ""),
+ {historyLoading() ? "Loading earlier messages..." : "Load earlier messages"}
+ </Button>
+ </div>
+ </Show>
+ <For each={visibleUserMessages()}>
+ {(message) => {
+ if (import.meta.env.DEV) {
+ onMount(() => {
+ const id = params.id
+ if (!id) return
+ navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
+ })
+ }
+
+ return (
+ <div
+ id={anchor(message.id)}
+ data-message-id={message.id}
+ classList={{
+ "min-w-0 w-full max-w-full": true,
+ "last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
+ platform.platform !== "desktop",
+ "last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
+ platform.platform === "desktop",
}}
- />
- </div>
- )}
+ >
+ <SessionTurn
+ sessionID={params.id!}
+ messageID={message.id}
+ lastUserMessageID={lastUserMessage()?.id}
+ stepsExpanded={store.expanded[message.id] ?? false}
+ onStepsExpandedToggle={() =>
+ setStore("expanded", message.id, (open: boolean | undefined) => !open)
+ }
+ classes={{
+ root: "min-w-0 w-full relative",
+ content:
+ "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
+ container:
+ "px-4 md:px-6 " +
+ (!showTabs()
+ ? "md:max-w-200 md:mx-auto"
+ : visibleUserMessages().length > 1
+ ? "md:pr-6 md:pl-18"
+ : ""),
+ }}
+ />
+ </div>
+ )
+ }}
</For>
</div>
</div>
@@ -1035,17 +1137,22 @@ export default function Page() {
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
- <SessionReviewTab
- diffs={diffs}
- view={view}
- diffStyle={layout.review.diffStyle()}
- onDiffStyleChange={layout.review.setDiffStyle}
- onViewFile={(path) => {
- const value = file.tab(path)
- tabs().open(value)
- file.load(path)
- }}
- />
+ <Show
+ when={diffsReady()}
+ fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
+ >
+ <SessionReviewTab
+ diffs={diffs}
+ view={view}
+ diffStyle={layout.review.diffStyle()}
+ onDiffStyleChange={layout.review.setDiffStyle}
+ onViewFile={(path) => {
+ const value = file.tab(path)
+ tabs().open(value)
+ file.load(path)
+ }}
+ />
+ </Show>
</div>
</Tabs.Content>
</Show>
diff --git a/packages/app/src/utils/perf.ts b/packages/app/src/utils/perf.ts
new file mode 100644
index 000000000..0ecbc33ff
--- /dev/null
+++ b/packages/app/src/utils/perf.ts
@@ -0,0 +1,135 @@
+type Nav = {
+ id: string
+ dir?: string
+ from?: string
+ to: string
+ trigger?: string
+ start: number
+ marks: Record<string, number>
+ logged: boolean
+ timer?: ReturnType<typeof setTimeout>
+}
+
+const dev = import.meta.env.DEV
+
+const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
+
+const now = () => performance.now()
+
+const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2)
+
+const navs = new Map<string, Nav>()
+const pending = new Map<string, string>()
+const active = new Map<string, string>()
+
+const required = [
+ "session:params",
+ "session:data-ready",
+ "session:first-turn-mounted",
+ "storage:prompt-ready",
+ "storage:terminal-ready",
+ "storage:file-view-ready",
+]
+
+function flush(id: string, reason: "complete" | "timeout") {
+ if (!dev) return
+ const nav = navs.get(id)
+ if (!nav) return
+ if (nav.logged) return
+
+ nav.logged = true
+ if (nav.timer) clearTimeout(nav.timer)
+
+ const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
+ const base = nav.marks[baseName] ?? nav.start
+
+ const ms = Object.fromEntries(
+ Object.entries(nav.marks)
+ .slice()
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
+ )
+
+ console.log(
+ "perf.session-nav " +
+ JSON.stringify({
+ type: "perf.session-nav.v0",
+ id: nav.id,
+ dir: nav.dir,
+ from: nav.from,
+ to: nav.to,
+ trigger: nav.trigger,
+ base: baseName,
+ reason,
+ ms,
+ }),
+ )
+
+ navs.delete(id)
+}
+
+function maybeFlush(id: string) {
+ if (!dev) return
+ const nav = navs.get(id)
+ if (!nav) return
+ if (nav.logged) return
+ if (!required.every((name) => nav.marks[name] !== undefined)) return
+ flush(id, "complete")
+}
+
+function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
+ const existing = navs.get(id)
+ if (existing) return existing
+
+ const nav: Nav = {
+ ...data,
+ marks: {},
+ logged: false,
+ }
+ nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
+ navs.set(id, nav)
+ return nav
+}
+
+export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
+ if (!dev) return
+
+ const id = uid()
+ const start = now()
+ const nav = ensure(id, { ...input, id, start })
+ nav.marks["navigate:start"] = start
+
+ pending.set(key(input.dir, input.to), id)
+ return id
+}
+
+export function navParams(input: { dir?: string; from?: string; to: string }) {
+ if (!dev) return
+
+ const k = key(input.dir, input.to)
+ const pendingId = pending.get(k)
+ if (pendingId) pending.delete(k)
+ const id = pendingId ?? uid()
+
+ const start = now()
+ const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
+ nav.marks["session:params"] = start
+
+ active.set(k, id)
+ maybeFlush(id)
+ return id
+}
+
+export function navMark(input: { dir?: string; to: string; name: string }) {
+ if (!dev) return
+
+ const id = active.get(key(input.dir, input.to))
+ if (!id) return
+
+ const nav = navs.get(id)
+ if (!nav) return
+ if (nav.marks[input.name] !== undefined) return
+
+ nav.marks[input.name] = now()
+ maybeFlush(id)
+}