Просмотр исходного кода

feat(app): chunk message loading, lazy load diffs

Adam 1 месяц назад
Родитель
Сommit
374275eeb6

+ 144 - 56
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)

+ 17 - 0
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))
   }

+ 175 - 68
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>

+ 135 - 0
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)
+}