Adam 1 месяц назад
Родитель
Сommit
260eef2d66
1 измененных файлов с 204 добавлено и 147 удалено
  1. 204 147
      packages/app/src/pages/session.tsx

+ 204 - 147
packages/app/src/pages/session.tsx

@@ -1,18 +1,4 @@
-import {
-  For,
-  onCleanup,
-  onMount,
-  Show,
-  Match,
-  Switch,
-  createMemo,
-  createEffect,
-  on,
-  createRenderEffect,
-  batch,
-  createSignal,
-} from "solid-js"
-
+import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, createRenderEffect, batch } from "solid-js"
 import { Dynamic } from "solid-js/web"
 import { useLocal } from "@/context/local"
 import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file"
@@ -28,7 +14,6 @@ import { Tabs } from "@opencode-ai/ui/tabs"
 import { useCodeComponent } from "@opencode-ai/ui/context/code"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { createAutoScroll } from "@opencode-ai/ui/hooks"
-
 import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
@@ -80,7 +65,6 @@ export default function Page() {
   const navigate = useNavigate()
   const sdk = useSDK()
   const prompt = usePrompt()
-
   const permission = usePermission()
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey()))
@@ -140,6 +124,11 @@ export default function Page() {
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const revertMessageID = createMemo(() => info()?.revert?.messageID)
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+  const messagesReady = createMemo(() => {
+    const id = params.id
+    if (!id) return true
+    return sync.data.message[id] !== undefined
+  })
   const emptyUserMessages: UserMessage[] = []
   const userMessages = createMemo(
     () => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -176,11 +165,13 @@ export default function Page() {
     stepsExpanded: true,
     mobileStepsExpanded: {} as Record<string, boolean>,
     messageId: undefined as string | undefined,
+    mobileTab: "session" as "session" | "review",
+    ignoreScrollSpy: false,
+    initialScrollDone: !params.id,
   })
 
   const activeMessage = createMemo(() => {
     if (!store.messageId) return lastUserMessage()
-    // If the stored message is no longer visible (e.g., was reverted), fall back to last visible
     const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
     return found ?? lastUserMessage()
   })
@@ -204,11 +195,12 @@ export default function Page() {
 
     if (targetIndex < 0 || targetIndex >= msgs.length) return
 
-    setActiveMessage(msgs[targetIndex])
+    scrollToMessage(msgs[targetIndex], "auto")
   }
 
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
 
+  const idle = { type: "idle" as const }
   let inputRef!: HTMLDivElement
 
   createEffect(() => {
@@ -236,8 +228,6 @@ export default function Page() {
     ),
   )
 
-  const idle = { type: "idle" as const }
-
   createEffect(
     on(
       () => params.id,
@@ -498,14 +488,6 @@ export default function Page() {
     }
   }
 
-  onMount(() => {
-    document.addEventListener("keydown", handleKeyDown)
-  })
-
-  onCleanup(() => {
-    document.removeEventListener("keydown", handleKeyDown)
-  })
-
   const handleDragStart = (event: unknown) => {
     const id = getDraggableId(event)
     if (!id) return
@@ -587,27 +569,68 @@ export default function Page() {
     onUserInteracted: () => setStore("userInteracted", true),
   })
 
-  // Mobile tab state for Session/Review switching (only affects mobile layout)
-  const [mobileTab, setMobileTab] = createSignal<"session" | "review">("session")
+  let scrollContainer: HTMLDivElement | undefined
+  let initialScrollFrame: number | undefined
+  let initialScrollTarget: string | undefined
+
+  const cancelInitialScroll = () => {
+    if (initialScrollFrame === undefined) return
+    cancelAnimationFrame(initialScrollFrame)
+    initialScrollFrame = undefined
+  }
+
+  const ensureInitialScroll = () => {
+    cancelInitialScroll()
+    initialScrollFrame = requestAnimationFrame(() => {
+      initialScrollFrame = undefined
+      if (!params.id) {
+        initialScrollTarget = undefined
+        setStore("initialScrollDone", true)
+        return
+      }
+      const msgs = visibleUserMessages()
+      if (msgs.length === 0) {
+        if (!messagesReady()) {
+          ensureInitialScroll()
+          return
+        }
+        initialScrollTarget = undefined
+        setStore("initialScrollDone", true)
+        return
+      }
+      const last = msgs[msgs.length - 1]
+      const el = messageRefs.get(last.id)
+      if (!el || !scrollContainer) {
+        ensureInitialScroll()
+        return
+      }
+      scrollToMessage(last, "auto")
+      initialScrollTarget = last.id
+      setStore("initialScrollDone", true)
+    })
+  }
+
+  const setScrollRef = (el: HTMLDivElement | undefined) => {
+    scrollContainer = el
+    autoScroll.scrollRef(el)
+  }
 
-  // Track message element refs for scrolling
   const messageRefs = new Map<string, HTMLDivElement>()
-  const [ignoreScrollSpy, setIgnoreScrollSpy] = createSignal(false)
   let scrollTimer: number
 
-  const scrollToMessage = (message: UserMessage) => {
-    setIgnoreScrollSpy(true)
+  const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
+    setStore("ignoreScrollSpy", true)
     setActiveMessage(message)
     const el = messageRefs.get(message.id)
     if (el) {
-      el.scrollIntoView({ behavior: "smooth", block: "start" })
+      el.scrollIntoView({ behavior, block: "start" })
     }
     window.clearTimeout(scrollTimer)
-    scrollTimer = window.setTimeout(() => setIgnoreScrollSpy(false), 1000)
+    scrollTimer = window.setTimeout(() => setStore("ignoreScrollSpy", false), 1000)
   }
 
   const handleScrollSpy = (e: Event) => {
-    if (ignoreScrollSpy()) return
+    if (store.ignoreScrollSpy) return
     const container = e.target as HTMLDivElement
     const scrollTop = container.scrollTop
     const threshold = 100
@@ -616,7 +639,6 @@ export default function Page() {
     for (const message of visibleUserMessages()) {
       const el = messageRefs.get(message.id)
       if (!el) continue
-
       if (el.offsetTop <= scrollTop + threshold) {
         activeId = message.id
       } else {
@@ -629,117 +651,69 @@ export default function Page() {
     }
   }
 
+  createEffect(
+    on(
+      () => params.id,
+      (id) => {
+        cancelInitialScroll()
+        messageRefs.clear()
+        initialScrollTarget = undefined
+        setStore("initialScrollDone", !id)
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(() => {
+    const msgs = visibleUserMessages()
+    const target = msgs.at(-1)?.id
+    const ready = messagesReady()
+
+    if (!params.id) {
+      setStore("initialScrollDone", true)
+      initialScrollTarget = undefined
+      return
+    }
+
+    if (!ready) {
+      setStore("initialScrollDone", false)
+      ensureInitialScroll()
+      return
+    }
+
+    if (!store.initialScrollDone) {
+      ensureInitialScroll()
+      return
+    }
+
+    if (!initialScrollTarget && target) {
+      setStore("initialScrollDone", false)
+      ensureInitialScroll()
+    }
+  })
+
   createEffect(() => {
     const msgs = visibleUserMessages()
     if (msgs.length === 0) return
-    // Wait for refs to be populated
     requestAnimationFrame(() => {
-      const container = autoScroll.scrollRef
-      if (!container) return
+      if (!scrollContainer) return
       // Manually trigger spy once to set initial active message based on scroll position
-      handleScrollSpy({ target: container } as unknown as Event)
+      handleScrollSpy({ target: scrollContainer } as unknown as Event)
     })
   })
 
-  // Unified SessionTurns component - works for both mobile and desktop
-  const SessionTurns = () => (
-    <div class="relative w-full h-full min-w-0">
-      {/* Message rail - hidden on mobile, positioned absolutely over the content */}
-      <div class="hidden md:block absolute inset-0 pointer-events-none z-10">
-        <SessionMessageRail
-          messages={visibleUserMessages()}
-          current={activeMessage()}
-          onMessageSelect={scrollToMessage}
-          wide={!showTabs()}
-          class="pointer-events-auto"
-        />
-      </div>
-      <div
-        ref={autoScroll.scrollRef}
-        onScroll={(e) => {
-          autoScroll.handleScroll()
-          handleScrollSpy(e)
-        }}
-        onClick={autoScroll.handleInteraction}
-        class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar snap-y snap-proximity"
-      >
-        <div
-          ref={autoScroll.contentRef}
-          class="flex flex-col gap-45 items-start justify-start pb-32 md:pb-40 transition-[margin]"
-          classList={{
-            "mt-0.5": !showTabs(),
-            "mt-1": showTabs(),
-          }}
-        >
-          <For each={visibleUserMessages()}>
-            {(message) => (
-              <div
-                ref={(el) => messageRefs.set(message.id, el)}
-                class="min-w-0 w-full max-w-full snap-start scroll-m-4 last:min-h-[80vh]"
-              >
-                <SessionTurn
-                  sessionID={params.id!}
-                  messageID={message.id}
-                  lastUserMessageID={lastUserMessage()?.id}
-                  stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
-                  onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
-                  onUserInteracted={() => setStore("userInteracted", true)}
-                  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>
-    </div>
-  )
-
-  // Session content component - unified for mobile and desktop
-  const SessionContent = () => (
-    <Switch>
-      <Match when={params.id}>
-        <SessionTurns />
-      </Match>
-      <Match when={true}>
-        <NewSessionView />
-      </Match>
-    </Switch>
-  )
+  createEffect(() => {
+    document.addEventListener("keydown", handleKeyDown)
+  })
 
-  // Review panel content - used on both mobile (via tabs) and desktop (side panel)
-  const ReviewPanel = () => (
-    <div class="relative h-full overflow-y-auto no-scrollbar">
-      <SessionReview
-        diffs={diffs()}
-        diffStyle={layout.review.diffStyle()}
-        onDiffStyleChange={layout.review.setDiffStyle}
-        open={view().review.open()}
-        onOpenChange={view().review.setOpen}
-        classes={{
-          root: "pb-32",
-          header: "px-4",
-          container: "px-4",
-        }}
-      />
-    </div>
-  )
+  onCleanup(() => {
+    document.removeEventListener("keydown", handleKeyDown)
+    cancelInitialScroll()
+  })
 
   return (
     <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
       <SessionHeader />
-
-      {/* Main content area - responsive layout */}
       <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={diffs().length > 0}>
@@ -748,10 +722,10 @@ export default function Page() {
               type="button"
               class="flex-1 py-3 text-14-medium border-b-2 transition-colors"
               classList={{
-                "border-text-base text-text-base": mobileTab() === "session",
-                "border-transparent text-text-weak": mobileTab() !== "session",
+                "border-text-base text-text-base": store.mobileTab === "session",
+                "border-transparent text-text-weak": store.mobileTab !== "session",
               }}
-              onClick={() => setMobileTab("session")}
+              onClick={() => setStore("mobileTab", "session")}
             >
               Session
             </button>
@@ -759,10 +733,10 @@ export default function Page() {
               type="button"
               class="flex-1 py-3 text-14-medium border-b-2 transition-colors"
               classList={{
-                "border-text-base text-text-base": mobileTab() === "review",
-                "border-transparent text-text-weak": mobileTab() !== "review",
+                "border-text-base text-text-base": store.mobileTab === "review",
+                "border-transparent text-text-weak": store.mobileTab !== "review",
               }}
-              onClick={() => setMobileTab("review")}
+              onClick={() => setStore("mobileTab", "review")}
             >
               {diffs().length} Files Changed
             </button>
@@ -774,13 +748,83 @@ export default function Page() {
           class="@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger md:py-3"
           classList={{
             // Mobile: hide when review tab is active and there are diffs
-            "hidden md:flex": diffs().length > 0 && mobileTab() === "review",
+            "hidden md:flex": diffs().length > 0 && store.mobileTab === "review",
             "flex-1 md:flex-none": true,
           }}
           style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
         >
           <div class="flex-1 min-h-0 overflow-hidden">
-            <SessionContent />
+            <Show when={activeMessage()}>
+              <Switch>
+                <Match when={params.id}>
+                  <div class="relative w-full h-full min-w-0">
+                    <div class="hidden md:block absolute inset-0 pointer-events-none z-10">
+                      <SessionMessageRail
+                        messages={visibleUserMessages()}
+                        current={activeMessage()}
+                        onMessageSelect={scrollToMessage}
+                        wide={!showTabs()}
+                        class="pointer-events-auto"
+                      />
+                    </div>
+                    <div
+                      ref={setScrollRef}
+                      onScroll={(e) => {
+                        autoScroll.handleScroll()
+                        handleScrollSpy(e)
+                      }}
+                      onClick={autoScroll.handleInteraction}
+                      class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
+                      classList={{
+                        "opacity-0 pointer-events-none": !store.initialScrollDone,
+                      }}
+                    >
+                      <div
+                        ref={autoScroll.contentRef}
+                        class="flex flex-col gap-45 items-start justify-start pb-32 md:pb-40 transition-[margin]"
+                        classList={{
+                          "mt-0.5": !showTabs(),
+                          "mt-0": showTabs(),
+                        }}
+                      >
+                        <For each={visibleUserMessages()}>
+                          {(message) => (
+                            <div
+                              ref={(el) => messageRefs.set(message.id, el)}
+                              class="min-w-0 w-full max-w-full last:min-h-[80vh]"
+                            >
+                              <SessionTurn
+                                sessionID={params.id!}
+                                messageID={message.id}
+                                lastUserMessageID={lastUserMessage()?.id}
+                                stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
+                                onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
+                                onUserInteracted={() => setStore("userInteracted", true)}
+                                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>
+                  </div>
+                </Match>
+                <Match when={true}>
+                  <NewSessionView />
+                </Match>
+              </Switch>
+            </Show>
           </div>
 
           {/* Prompt input */}
@@ -811,9 +855,22 @@ export default function Page() {
         </div>
 
         {/* Mobile review panel - only shown on mobile when review tab is active */}
-        <Show when={diffs().length > 0 && mobileTab() === "review"}>
+        <Show when={diffs().length > 0 && store.mobileTab === "review"}>
           <div class="md:hidden flex-1 min-h-0 mt-6 pb-32">
-            <ReviewPanel />
+            <div class="relative h-full overflow-y-auto no-scrollbar">
+              <SessionReview
+                diffs={diffs()}
+                diffStyle={layout.review.diffStyle()}
+                onDiffStyleChange={layout.review.setDiffStyle}
+                open={view().review.open()}
+                onOpenChange={view().review.setOpen}
+                classes={{
+                  root: "pb-32",
+                  header: "px-4",
+                  container: "px-4",
+                }}
+              />
+            </div>
           </div>
         </Show>