Adam 2 месяцев назад
Родитель
Сommit
307af10c8b
2 измененных файлов с 62 добавлено и 54 удалено
  1. 17 12
      packages/ui/src/components/session-turn.css
  2. 45 42
      packages/ui/src/components/session-turn.tsx

+ 17 - 12
packages/ui/src/components/session-turn.css

@@ -1,5 +1,6 @@
 [data-component="session-turn"] {
   /* flex: 1; */
+  --scroll-y: 0px;
   height: 100%;
   min-height: 0;
   min-width: 0;
@@ -26,18 +27,26 @@
     align-items: flex-start;
     align-self: stretch;
     min-width: 0;
-    gap: 32px;
+    gap: clamp(8px, calc(42px - var(--scroll-y) * 0.48), 42px);
   }
 
-  [data-slot="session-turn-sticky-header"] {
+  [data-slot="session-turn-sticky-title"] {
     width: 100%;
     position: sticky;
     top: 0;
     background-color: var(--background-stronger);
+    z-index: 21;
+    /* padding-bottom: clamp(0px, calc(8px - var(--scroll-y) * 0.16), 8px); */
+  }
+
+  [data-slot="session-turn-response-trigger"] {
+    position: sticky;
+    top: 32px;
+    background-color: var(--background-stronger);
     z-index: 20;
-    display: flex;
-    flex-direction: column;
-    gap: 8px;
+    width: calc(100% + 9px);
+    margin-left: -9px;
+    padding-left: 9px;
     padding-bottom: 8px;
   }
 
@@ -49,13 +58,8 @@
     height: 32px;
   }
 
-  /* [data-slot="session-turn-message-content"] { */
-  /* } */
-
-  [data-slot="session-turn-response-trigger"] {
-    width: calc(100% + 9px);
-    margin-left: -9px;
-    padding-left: 9px;
+  [data-slot="session-turn-message-content"] {
+    margin-top: -24px;
   }
 
   [data-slot="session-turn-message-title"] {
@@ -292,6 +296,7 @@
   [data-slot="session-turn-collapsible"] {
     gap: 32px;
     overflow: visible;
+    /* margin-top: clamp(8px, calc(24px - var(--scroll-y) * 0.32), 24px); */
   }
 
   [data-slot="session-turn-collapsible-trigger-content"] {

+ 45 - 42
packages/ui/src/components/session-turn.tsx

@@ -3,18 +3,7 @@ import { useData } from "../context"
 import { useDiffComponent } from "../context/diff"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { checksum } from "@opencode-ai/util/encode"
-import {
-  createEffect,
-  createMemo,
-  createSignal,
-  For,
-  Match,
-  onCleanup,
-  onMount,
-  ParentProps,
-  Show,
-  Switch,
-} from "solid-js"
+import { createEffect, createMemo, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { DiffChanges } from "./diff-changes"
 import { Typewriter } from "./typewriter"
@@ -61,12 +50,15 @@ export function SessionTurn(
 
   let scrollRef: HTMLDivElement | undefined
   const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
-  const [stickyHeaderRef, setStickyHeaderRef] = createSignal<HTMLDivElement>()
+  const [stickyTitleRef, setStickyTitleRef] = createSignal<HTMLDivElement>()
+  const [stickyTriggerRef, setStickyTriggerRef] = createSignal<HTMLDivElement>()
   const [userScrolled, setUserScrolled] = createSignal(false)
   const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0)
+  const [scrollY, setScrollY] = createSignal(0)
 
   function handleScroll() {
     if (!scrollRef) return
+    setScrollY(scrollRef.scrollTop)
     const { scrollTop, scrollHeight, clientHeight } = scrollRef
     const atBottom = scrollHeight - scrollTop - clientHeight < 50
     if (!atBottom && working()) {
@@ -88,15 +80,24 @@ export function SessionTurn(
 
   createResizeObserver(contentRef, () => {
     if (!scrollRef || userScrolled() || !working()) return
-    scrollRef.scrollTop = scrollRef.scrollHeight
+    requestAnimationFrame(() => {
+      if (!scrollRef) return
+      scrollRef.scrollTop = scrollRef.scrollHeight
+    })
   })
 
-  createResizeObserver(stickyHeaderRef, ({ height }) => {
-    setStickyHeaderHeight(height + 8)
+  createResizeObserver(stickyTitleRef, ({ height }) => {
+    const triggerHeight = stickyTriggerRef()?.offsetHeight ?? 0
+    setStickyHeaderHeight(height + triggerHeight + 8)
+  })
+
+  createResizeObserver(stickyTriggerRef, ({ height }) => {
+    const titleHeight = stickyTitleRef()?.offsetHeight ?? 0
+    setStickyHeaderHeight(titleHeight + height + 8)
   })
 
   return (
-    <div data-component="session-turn" class={props.classes?.root}>
+    <div data-component="session-turn" class={props.classes?.root} style={{ "--scroll-y": `${scrollY()}px` }}>
       <div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
         <div ref={setContentRef} onClick={handleInteraction}>
           <Show when={message()}>
@@ -250,8 +251,8 @@ export function SessionTurn(
                   class={props.classes?.container}
                   style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }}
                 >
-                  {/* Sticky Header */}
-                  <div ref={setStickyHeaderRef} data-slot="session-turn-sticky-header">
+                  {/* Title (sticky) */}
+                  <div ref={setStickyTitleRef} data-slot="session-turn-sticky-title">
                     <div data-slot="session-turn-message-header">
                       <div data-slot="session-turn-message-title">
                         <Switch>
@@ -264,29 +265,31 @@ export function SessionTurn(
                         </Switch>
                       </div>
                     </div>
-                    <div data-slot="session-turn-message-content">
-                      <Message message={message()} parts={parts()} />
-                    </div>
-                    <div data-slot="session-turn-response-trigger">
-                      <Button
-                        data-slot="session-turn-collapsible-trigger-content"
-                        variant="ghost"
-                        size="small"
-                        onClick={() => setStore("stepsExpanded", !store.stepsExpanded)}
-                      >
-                        <Show when={working()}>
-                          <Spinner />
-                        </Show>
-                        <Switch>
-                          <Match when={working()}>{store.status ?? "Considering next steps..."}</Match>
-                          <Match when={store.stepsExpanded}>Hide steps</Match>
-                          <Match when={!store.stepsExpanded}>Show steps</Match>
-                        </Switch>
-                        <span>·</span>
-                        <span>{store.duration}</span>
-                        <Icon name="chevron-grabber-vertical" size="small" />
-                      </Button>
-                    </div>
+                  </div>
+                  {/* User Message (non-sticky, scrolls under sticky header) */}
+                  <div data-slot="session-turn-message-content">
+                    <Message message={message()} parts={parts()} />
+                  </div>
+                  {/* Trigger (sticky) */}
+                  <div ref={setStickyTriggerRef} data-slot="session-turn-response-trigger">
+                    <Button
+                      data-slot="session-turn-collapsible-trigger-content"
+                      variant="ghost"
+                      size="small"
+                      onClick={() => setStore("stepsExpanded", !store.stepsExpanded)}
+                    >
+                      <Show when={working()}>
+                        <Spinner />
+                      </Show>
+                      <Switch>
+                        <Match when={working()}>{store.status ?? "Considering next steps..."}</Match>
+                        <Match when={store.stepsExpanded}>Hide steps</Match>
+                        <Match when={!store.stepsExpanded}>Show steps</Match>
+                      </Switch>
+                      <span>·</span>
+                      <span>{store.duration}</span>
+                      <Icon name="chevron-grabber-vertical" size="small" />
+                    </Button>
                   </div>
                   {/* Response */}
                   <Show when={store.stepsExpanded}>