2
0
Эх сурвалжийг харах

feat(app): incrementally render turns, markdown cache, lazily render diffs

Adam 1 сар өмнө
parent
commit
c949e5b390

+ 231 - 90
packages/app/src/pages/session.tsx

@@ -309,11 +309,20 @@ export default function Page() {
     activeTerminalDraggable: undefined as string | undefined,
     expanded: {} as Record<string, boolean>,
     messageId: undefined as string | undefined,
+    turnStart: 0,
     mobileTab: "session" as "session" | "review",
     newSessionWorktree: "main",
     promptHeight: 0,
   })
 
+  const renderedUserMessages = createMemo(() => {
+    const msgs = visibleUserMessages()
+    const start = store.turnStart
+    if (start <= 0) return msgs
+    if (start >= msgs.length) return emptyUserMessages
+    return msgs.slice(start)
+  }, emptyUserMessages)
+
   const newSessionWorktree = createMemo(() => {
     if (store.newSessionWorktree === "create") return "create"
     const project = sync.project
@@ -758,6 +767,88 @@ export default function Page() {
     autoScroll.scrollRef(el)
   }
 
+  const turnInit = 20
+  const turnBatch = 20
+  let turnHandle: number | undefined
+  let turnIdle = false
+
+  function cancelTurnBackfill() {
+    const handle = turnHandle
+    if (handle === undefined) return
+    turnHandle = undefined
+
+    if (turnIdle && window.cancelIdleCallback) {
+      window.cancelIdleCallback(handle)
+      return
+    }
+
+    clearTimeout(handle)
+  }
+
+  function scheduleTurnBackfill() {
+    if (turnHandle !== undefined) return
+    if (store.turnStart <= 0) return
+
+    if (window.requestIdleCallback) {
+      turnIdle = true
+      turnHandle = window.requestIdleCallback(() => {
+        turnHandle = undefined
+        backfillTurns()
+      })
+      return
+    }
+
+    turnIdle = false
+    turnHandle = window.setTimeout(() => {
+      turnHandle = undefined
+      backfillTurns()
+    }, 0)
+  }
+
+  function backfillTurns() {
+    const start = store.turnStart
+    if (start <= 0) return
+
+    const next = start - turnBatch
+    const nextStart = next > 0 ? next : 0
+
+    const el = scroller
+    if (!el) {
+      setStore("turnStart", nextStart)
+      scheduleTurnBackfill()
+      return
+    }
+
+    const beforeTop = el.scrollTop
+    const beforeHeight = el.scrollHeight
+
+    setStore("turnStart", nextStart)
+
+    requestAnimationFrame(() => {
+      const delta = el.scrollHeight - beforeHeight
+      if (delta) el.scrollTop = beforeTop + delta
+    })
+
+    scheduleTurnBackfill()
+  }
+
+  createEffect(
+    on(
+      () => [params.id, messagesReady()] as const,
+      ([id, ready]) => {
+        cancelTurnBackfill()
+        setStore("turnStart", 0)
+        if (!id || !ready) return
+
+        const len = visibleUserMessages().length
+        const start = len > turnInit ? len - turnInit : 0
+        setStore("turnStart", start)
+        scheduleTurnBackfill()
+      },
+      { defer: true },
+    ),
+  )
+
   createResizeObserver(
     () => promptDock,
     ({ height }) => {
@@ -785,6 +876,21 @@ export default function Page() {
   const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
     setActiveMessage(message)
 
+    const msgs = visibleUserMessages()
+    const index = msgs.findIndex((m) => m.id === message.id)
+    if (index !== -1 && index < store.turnStart) {
+      setStore("turnStart", index)
+      scheduleTurnBackfill()
+
+      requestAnimationFrame(() => {
+        const el = document.getElementById(anchor(message.id))
+        if (el) el.scrollIntoView({ behavior, block: "start" })
+      })
+
+      updateHash(message.id)
+      return
+    }
+
     const el = document.getElementById(anchor(message.id))
     if (el) el.scrollIntoView({ behavior, block: "start" })
     updateHash(message.id)
@@ -830,12 +936,27 @@ export default function Page() {
     if (!sessionID || !ready) return
 
     requestAnimationFrame(() => {
-      const id = window.location.hash.slice(1)
-      const hashTarget = id ? document.getElementById(id) : undefined
+      const hash = window.location.hash.slice(1)
+      if (!hash) {
+        autoScroll.forceScrollToBottom()
+        return
+      }
+
+      const hashTarget = document.getElementById(hash)
       if (hashTarget) {
         hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
         return
       }
+
+      const match = hash.match(/^message-(.+)$/)
+      if (match) {
+        const msg = visibleUserMessages().find((m) => m.id === match[1])
+        if (msg) {
+          scrollToMessage(msg, "auto")
+          return
+        }
+      }
+
       autoScroll.forceScrollToBottom()
     })
   })
@@ -868,6 +989,7 @@ export default function Page() {
           return [[path, file.selectedLines(path) ?? null] as const]
         }),
     )
+    cancelTurnBackfill()
     document.removeEventListener("keydown", handleKeyDown)
     if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
   })
@@ -971,6 +1093,18 @@ export default function Page() {
                             "mt-0": showTabs(),
                           }}
                         >
+                          <Show when={store.turnStart > 0}>
+                            <div class="w-full flex justify-center">
+                              <Button
+                                variant="ghost"
+                                size="large"
+                                class="text-12-medium opacity-50"
+                                onClick={() => setStore("turnStart", 0)}
+                              >
+                                Render earlier messages
+                              </Button>
+                            </div>
+                          </Show>
                           <Show when={historyMore()}>
                             <div class="w-full flex justify-center">
                               <Button
@@ -981,6 +1115,7 @@ export default function Page() {
                                 onClick={() => {
                                   const id = params.id
                                   if (!id) return
+                                  setStore("turnStart", 0)
                                   sync.session.history.loadMore(id)
                                 }}
                               >
@@ -988,7 +1123,7 @@ export default function Page() {
                               </Button>
                             </div>
                           </Show>
-                          <For each={visibleUserMessages()}>
+                          <For each={renderedUserMessages()}>
                             {(message) => {
                               if (import.meta.env.DEV) {
                                 onMount(() => {
@@ -1173,36 +1308,40 @@ export default function Page() {
                 </div>
                 <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">
-                      <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>
+                    <Show when={activeTab() === "review"}>
+                      <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                        <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>
+                    </Show>
                   </Tabs.Content>
                 </Show>
                 <Show when={contextOpen()}>
                   <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
-                    <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-                      <SessionContextTab
-                        messages={messages}
-                        visibleUserMessages={visibleUserMessages}
-                        view={view}
-                        info={info}
-                      />
-                    </div>
+                    <Show when={activeTab() === "context"}>
+                      <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                        <SessionContextTab
+                          messages={messages}
+                          visibleUserMessages={visibleUserMessages}
+                          view={view}
+                          info={info}
+                        />
+                      </div>
+                    </Show>
                   </Tabs.Content>
                 </Show>
                 <For each={openedTabs()}>
@@ -1349,37 +1488,63 @@ export default function Page() {
                         }}
                         onScroll={handleScroll}
                       >
-                        <Show when={selection()}>
-                          {(sel) => (
-                            <div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
-                              <button
-                                type="button"
-                                class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
-                                onClick={() => {
-                                  const p = path()
-                                  if (!p) return
-                                  prompt.context.add({ type: "file", path: p, selection: sel() })
-                                }}
-                              >
-                                <Icon name="plus-small" size="small" />
-                                <span>Add {selectionLabel()} to context</span>
-                              </button>
-                            </div>
-                          )}
-                        </Show>
-                        <Switch>
-                          <Match when={state()?.loaded && isImage()}>
-                            <div class="px-6 py-4 pb-40">
-                              <img src={imageDataUrl()} alt={path()} class="max-w-full" />
-                            </div>
-                          </Match>
-                          <Match when={state()?.loaded && isSvg()}>
-                            <div class="flex flex-col gap-4 px-6 py-4">
+                        <Show when={activeTab() === tab}>
+                          <Show when={selection()}>
+                            {(sel) => (
+                              <div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
+                                <button
+                                  type="button"
+                                  class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
+                                  onClick={() => {
+                                    const p = path()
+                                    if (!p) return
+                                    prompt.context.add({ type: "file", path: p, selection: sel() })
+                                  }}
+                                >
+                                  <Icon name="plus-small" size="small" />
+                                  <span>Add {selectionLabel()} to context</span>
+                                </button>
+                              </div>
+                            )}
+                          </Show>
+                          <Switch>
+                            <Match when={state()?.loaded && isImage()}>
+                              <div class="px-6 py-4 pb-40">
+                                <img src={imageDataUrl()} alt={path()} class="max-w-full" />
+                              </div>
+                            </Match>
+                            <Match when={state()?.loaded && isSvg()}>
+                              <div class="flex flex-col gap-4 px-6 py-4">
+                                <Dynamic
+                                  component={codeComponent}
+                                  file={{
+                                    name: path() ?? "",
+                                    contents: svgContent() ?? "",
+                                    cacheKey: cacheKey(),
+                                  }}
+                                  enableLineSelection
+                                  selectedLines={selectedLines()}
+                                  onLineSelected={(range: SelectedLineRange | null) => {
+                                    const p = path()
+                                    if (!p) return
+                                    file.setSelectedLines(p, range)
+                                  }}
+                                  overflow="scroll"
+                                  class="select-text"
+                                />
+                                <Show when={svgPreviewUrl()}>
+                                  <div class="flex justify-center pb-40">
+                                    <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
+                                  </div>
+                                </Show>
+                              </div>
+                            </Match>
+                            <Match when={state()?.loaded}>
                               <Dynamic
                                 component={codeComponent}
                                 file={{
                                   name: path() ?? "",
-                                  contents: svgContent() ?? "",
+                                  contents: contents(),
                                   cacheKey: cacheKey(),
                                 }}
                                 enableLineSelection
@@ -1390,41 +1555,17 @@ export default function Page() {
                                   file.setSelectedLines(p, range)
                                 }}
                                 overflow="scroll"
-                                class="select-text"
+                                class="select-text pb-40"
                               />
-                              <Show when={svgPreviewUrl()}>
-                                <div class="flex justify-center pb-40">
-                                  <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
-                                </div>
-                              </Show>
-                            </div>
-                          </Match>
-                          <Match when={state()?.loaded}>
-                            <Dynamic
-                              component={codeComponent}
-                              file={{
-                                name: path() ?? "",
-                                contents: contents(),
-                                cacheKey: cacheKey(),
-                              }}
-                              enableLineSelection
-                              selectedLines={selectedLines()}
-                              onLineSelected={(range: SelectedLineRange | null) => {
-                                const p = path()
-                                if (!p) return
-                                file.setSelectedLines(p, range)
-                              }}
-                              overflow="scroll"
-                              class="select-text pb-40"
-                            />
-                          </Match>
-                          <Match when={state()?.loading}>
-                            <div class="px-6 py-4 text-text-weak">Loading...</div>
-                          </Match>
-                          <Match when={state()?.error}>
-                            {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
-                          </Match>
-                        </Switch>
+                            </Match>
+                            <Match when={state()?.loading}>
+                              <div class="px-6 py-4 text-text-weak">Loading...</div>
+                            </Match>
+                            <Match when={state()?.error}>
+                              {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
+                            </Match>
+                          </Switch>
+                        </Show>
                       </Tabs.Content>
                     )
                   }}

+ 36 - 2
packages/ui/src/components/markdown.tsx

@@ -1,19 +1,53 @@
 import { useMarked } from "../context/marked"
+import { checksum } from "@opencode-ai/util/encode"
 import { ComponentProps, createResource, splitProps } from "solid-js"
 
+type Entry = {
+  hash: string
+  html: string
+}
+
+const max = 200
+const cache = new Map<string, Entry>()
+
+function touch(key: string, value: Entry) {
+  cache.delete(key)
+  cache.set(key, value)
+
+  if (cache.size <= max) return
+
+  const first = cache.keys().next().value
+  if (!first) return
+  cache.delete(first)
+}
+
 export function Markdown(
   props: ComponentProps<"div"> & {
     text: string
+    cacheKey?: string
     class?: string
     classList?: Record<string, boolean>
   },
 ) {
-  const [local, others] = splitProps(props, ["text", "class", "classList"])
+  const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
   const marked = useMarked()
   const [html] = createResource(
     () => local.text,
     async (markdown) => {
-      return marked.parse(markdown)
+      const hash = checksum(markdown)
+      const key = local.cacheKey ?? hash
+
+      if (key && hash) {
+        const cached = cache.get(key)
+        if (cached && cached.hash === hash) {
+          touch(key, cached)
+          return cached.html
+        }
+      }
+
+      const next = await marked.parse(markdown)
+      if (key && hash) touch(key, { hash, html: next })
+      return next
     },
     { initialValue: "" },
   )

+ 2 - 2
packages/ui/src/components/message-part.tsx

@@ -566,7 +566,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
   return (
     <Show when={throttledText()}>
       <div data-component="text-part">
-        <Markdown text={throttledText()} />
+        <Markdown text={throttledText()} cacheKey={part.id} />
       </div>
     </Show>
   )
@@ -580,7 +580,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
   return (
     <Show when={throttledText()}>
       <div data-component="reasoning-part">
-        <Markdown text={throttledText()} />
+        <Markdown text={throttledText()} cacheKey={part.id} />
       </div>
     </Show>
   )

+ 62 - 14
packages/ui/src/components/session-turn.tsx

@@ -350,15 +350,31 @@ export function SessionTurn(
     onUserInteracted: props.onUserInteracted,
   })
 
+  const diffInit = 20
+  const diffBatch = 20
+
   const [store, setStore] = createStore({
     stickyTitleRef: undefined as HTMLDivElement | undefined,
     stickyTriggerRef: undefined as HTMLDivElement | undefined,
     stickyHeaderHeight: 0,
     retrySeconds: 0,
+    diffsOpen: [] as string[],
+    diffLimit: diffInit,
     status: rawStatus(),
     duration: duration(),
   })
 
+  createEffect(
+    on(
+      () => message()?.id,
+      () => {
+        setStore("diffsOpen", [])
+        setStore("diffLimit", diffInit)
+      },
+      { defer: true },
+    ),
+  )
+
   createEffect(() => {
     const r = retry()
     if (!r) {
@@ -542,10 +558,23 @@ export function SessionTurn(
                       <div data-slot="session-turn-summary-section">
                         <div data-slot="session-turn-summary-header">
                           <h2 data-slot="session-turn-summary-title">Response</h2>
-                          <Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={response() ?? ""} />
+                          <Markdown
+                            data-slot="session-turn-markdown"
+                            data-diffs={hasDiffs()}
+                            text={response() ?? ""}
+                            cacheKey={responsePartId()}
+                          />
                         </div>
-                        <Accordion data-slot="session-turn-accordion" multiple>
-                          <For each={msg().summary?.diffs ?? []}>
+                        <Accordion
+                          data-slot="session-turn-accordion"
+                          multiple
+                          value={store.diffsOpen}
+                          onChange={(value) => {
+                            if (!Array.isArray(value)) return
+                            setStore("diffsOpen", value)
+                          }}
+                        >
+                          <For each={(msg().summary?.diffs ?? []).slice(0, store.diffLimit)}>
                             {(diff) => (
                               <Accordion.Item value={diff.file}>
                                 <StickyAccordionHeader>
@@ -573,22 +602,41 @@ export function SessionTurn(
                                   </Accordion.Trigger>
                                 </StickyAccordionHeader>
                                 <Accordion.Content data-slot="session-turn-accordion-content">
-                                  <Dynamic
-                                    component={diffComponent}
-                                    before={{
-                                      name: diff.file!,
-                                      contents: diff.before!,
-                                    }}
-                                    after={{
-                                      name: diff.file!,
-                                      contents: diff.after!,
-                                    }}
-                                  />
+                                  <Show when={store.diffsOpen.includes(diff.file!)}>
+                                    <Dynamic
+                                      component={diffComponent}
+                                      before={{
+                                        name: diff.file!,
+                                        contents: diff.before!,
+                                      }}
+                                      after={{
+                                        name: diff.file!,
+                                        contents: diff.after!,
+                                      }}
+                                    />
+                                  </Show>
                                 </Accordion.Content>
                               </Accordion.Item>
                             )}
                           </For>
                         </Accordion>
+                        <Show when={(msg().summary?.diffs?.length ?? 0) > store.diffLimit}>
+                          <Button
+                            data-slot="session-turn-accordion-more"
+                            variant="ghost"
+                            size="small"
+                            onClick={() => {
+                              const total = msg().summary?.diffs?.length ?? 0
+                              setStore("diffLimit", (limit) => {
+                                const next = limit + diffBatch
+                                if (next > total) return total
+                                return next
+                              })
+                            }}
+                          >
+                            Show more changes ({(msg().summary?.diffs?.length ?? 0) - store.diffLimit})
+                          </Button>
+                        </Show>
                       </div>
                     </Show>
                     <Show when={error() && !props.stepsExpanded}>