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

feat(app): improved session layout

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

+ 91 - 49
packages/app/src/pages/layout.tsx

@@ -28,13 +28,14 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { HoverCard } from "@opencode-ai/ui/hover-card"
+import { MessageNav } from "@opencode-ai/ui/message-nav"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Collapsible } from "@opencode-ai/ui/collapsible"
 import { DiffChanges } from "@opencode-ai/ui/diff-changes"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { getFilename } from "@opencode-ai/util/path"
-import { Session } from "@opencode-ai/sdk/v2/client"
+import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client"
 import { usePlatform } from "@/context/platform"
 import { createStore, produce, reconcile } from "solid-js/store"
 import {
@@ -1329,63 +1330,104 @@ export default function Layout(props: ParentProps) {
       return agent?.color
     })
 
+    const hoverMessages = createMemo(() =>
+      sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
+    )
+    const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
+    const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened())
+    const isActive = createMemo(() => props.session.id === params.id)
+
+    const messageLabel = (message: Message) => {
+      const parts = sessionStore.part[message.id] ?? []
+      const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
+      return text?.text
+    }
+
+    const item = (
+      <A
+        href={`${props.slug}/session/${props.session.id}`}
+        class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
+        onMouseEnter={() => prefetchSession(props.session, "high")}
+        onFocus={() => prefetchSession(props.session, "high")}
+      >
+        <div class="flex items-center gap-1 w-full">
+          <div
+            class="shrink-0 size-6 flex items-center justify-center"
+            style={{ color: tint() ?? "var(--icon-interactive-base)" }}
+          >
+            <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
+              <Match when={isWorking()}>
+                <Spinner class="size-[15px]" />
+              </Match>
+              <Match when={hasPermissions()}>
+                <div class="size-1.5 rounded-full bg-surface-warning-strong" />
+              </Match>
+              <Match when={hasError()}>
+                <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
+              </Match>
+              <Match when={notifications().length > 0}>
+                <div class="size-1.5 rounded-full bg-text-interactive-base" />
+              </Match>
+            </Switch>
+          </div>
+          <Tooltip
+            inactive={hoverAllowed()}
+            placement="top-start"
+            value={props.session.title}
+            gutter={0}
+            openDelay={3000}
+            class="grow-1 min-w-0"
+          >
+            <InlineEditor
+              id={`session:${props.session.id}`}
+              value={() => props.session.title}
+              onSave={(next) => renameSession(props.session, next)}
+              class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
+              displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
+              stopPropagation
+            />
+          </Tooltip>
+          <Show when={props.session.summary}>
+            {(summary) => (
+              <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
+                <DiffChanges changes={summary()} />
+              </div>
+            )}
+          </Show>
+        </div>
+      </A>
+    ))
+
     return (
       <div
         data-session-id={props.session.id}
         class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
                hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
       >
-        <A
-          href={`${props.slug}/session/${props.session.id}`}
-          class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
-          onMouseEnter={() => prefetchSession(props.session, "high")}
-          onFocus={() => prefetchSession(props.session, "high")}
+        <Show
+          when={hoverAllowed()}
+          fallback={item}
         >
-          <div class="flex items-center gap-1 w-full">
-            <div
-              class="shrink-0 size-6 flex items-center justify-center"
-              style={{ color: tint() ?? "var(--icon-interactive-base)" }}
-            >
-              <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
-                <Match when={isWorking()}>
-                  <Spinner class="size-[15px]" />
-                </Match>
-                <Match when={hasPermissions()}>
-                  <div class="size-1.5 rounded-full bg-surface-warning-strong" />
-                </Match>
-                <Match when={hasError()}>
-                  <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
-                </Match>
-                <Match when={notifications().length > 0}>
-                  <div class="size-1.5 rounded-full bg-text-interactive-base" />
-                </Match>
-              </Switch>
-            </div>
-            <Tooltip
-              placement="top-start"
-              value={props.session.title}
-              gutter={0}
-              openDelay={3000}
-              class="grow-1 min-w-0"
-            >
-              <InlineEditor
-                id={`session:${props.session.id}`}
-                value={() => props.session.title}
-                onSave={(next) => renameSession(props.session, next)}
-                class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
-                displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
-                stopPropagation
+          <HoverCard openDelay={150} closeDelay={100} placement="right" gutter={12} trigger={item}>
+            <Show when={hoverReady()} fallback={<div class="text-12-regular text-text-weak">Loading messages…</div>}>
+              <MessageNav
+                messages={hoverMessages() ?? []}
+                current={undefined}
+                getLabel={messageLabel}
+                onMessageSelect={(message) => {
+                  if (!isActive()) {
+                    navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
+                    return
+                  }
+                  window.location.hash = `message-${message.id}`
+                  window.dispatchEvent(new HashChangeEvent("hashchange"))
+                }}
+                size="normal"
+                class="w-60"
               />
-            </Tooltip>
-            <Show when={props.session.summary}>
-              {(summary) => (
-                <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
-                  <DiffChanges changes={summary()} />
-                </div>
-              )}
             </Show>
-          </div>
-        </A>
+          </HoverCard>
+        </Show>
         <div
           class={`hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"}`}
         >

+ 1 - 19
packages/app/src/pages/session.tsx

@@ -18,7 +18,6 @@ import { useCodeComponent } from "@opencode-ai/ui/context/code"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { createAutoScroll } from "@opencode-ai/ui/hooks"
 import { SessionReview } from "@opencode-ai/ui/session-review"
-import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
 
 import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
 import type { DragEvent } from "@thisbeyond/solid-dnd"
@@ -1163,17 +1162,6 @@ export default function Page() {
                     }
                   >
                     <div class="relative w-full h-full min-w-0">
-                      <Show when={isDesktop()}>
-                        <div class="absolute inset-0 pointer-events-none z-10">
-                          <SessionMessageRail
-                            messages={visibleUserMessages()}
-                            current={activeMessage()}
-                            onMessageSelect={scrollToMessage}
-                            wide={!showTabs()}
-                            class="pointer-events-auto"
-                          />
-                        </div>
-                      </Show>
                       <div
                         ref={setScrollRef}
                         onScroll={(e) => {
@@ -1255,13 +1243,7 @@ export default function Page() {
                                       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"
-                                            : ""),
+                                      container: "w-full px-4 md:px-6",
                                     }}
                                   />
                                 </div>

+ 2 - 19
packages/enterprise/src/routes/share/[shareID].tsx

@@ -16,7 +16,6 @@ import { iife } from "@opencode-ai/util/iife"
 import { Binary } from "@opencode-ai/util/binary"
 import { NamedError } from "@opencode-ai/util/error"
 import { DateTime } from "luxon"
-import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
 import { createStore } from "solid-js/store"
 import z from "zod"
 import NotFound from "../[...404]"
@@ -353,26 +352,16 @@ export default function () {
                                 <div
                                   classList={{
                                     "@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
-                                    "mx-auto max-w-200": !wide(),
                                   }}
                                 >
                                   <div
                                     classList={{
-                                      "w-full flex justify-start items-start min-w-0": true,
-                                      "max-w-200 mx-auto px-6": wide(),
-                                      "pr-6 pl-18": !wide() && messages().length > 1,
-                                      "px-6": !wide() && messages().length === 1,
+                                      "w-full flex justify-start items-start min-w-0 px-6": true,
                                     }}
                                   >
                                     {title()}
                                   </div>
                                   <div class="flex items-start justify-start h-full min-h-0">
-                                    <SessionMessageRail
-                                      messages={messages()}
-                                      current={activeMessage()}
-                                      onMessageSelect={setActiveMessage}
-                                      wide={wide()}
-                                    />
                                     <SessionTurn
                                       sessionID={data().sessionID}
                                       messageID={store.messageId ?? firstUserMessage()!.id!}
@@ -386,13 +375,7 @@ export default function () {
                                       classes={{
                                         root: "grow",
                                         content: "flex flex-col justify-between",
-                                        container:
-                                          "w-full pb-20 " +
-                                          (wide()
-                                            ? "max-w-200 mx-auto px-6"
-                                            : messages().length > 1
-                                              ? "pr-6 pl-18"
-                                              : "px-6"),
+                                        container: "w-full pb-20 px-6",
                                       }}
                                     >
                                       <div

+ 4 - 1
packages/ui/src/components/hover-card.css

@@ -1,5 +1,7 @@
 [data-slot="hover-card-trigger"] {
-  display: inline-flex;
+  display: flex;
+  width: 100%;
+  min-width: 0;
 }
 
 [data-component="hover-card-content"] {
@@ -8,6 +10,7 @@
   max-width: 320px;
   border-radius: var(--radius-md);
   background-color: var(--surface-raised-stronger-non-alpha);
+  pointer-events: auto;
 
   border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
   background-clip: padding-box;

+ 4 - 0
packages/ui/src/components/message-nav.css

@@ -10,6 +10,10 @@
     width: 240px;
     gap: 4px;
   }
+
+  &[data-size="compact"] {
+    width: 24px;
+  }
 }
 
 [data-slot="message-nav-item"] {

+ 19 - 5
packages/ui/src/components/message-nav.tsx

@@ -9,9 +9,10 @@ export function MessageNav(
     current?: UserMessage
     size: "normal" | "compact"
     onMessageSelect: (message: UserMessage) => void
+    getLabel?: (message: UserMessage) => string | undefined
   },
 ) {
-  const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"])
+  const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"])
 
   const content = () => (
     <ul role="list" data-component="message-nav" data-size={local.size} {...others}>
@@ -19,23 +20,36 @@ export function MessageNav(
         {(message) => {
           const handleClick = () => local.onMessageSelect(message)
 
+          const handleKeyPress = (event: KeyboardEvent) => {
+            if (event.key !== "Enter" && event.key !== " ") return
+            event.preventDefault()
+            local.onMessageSelect(message)
+          }
+
           return (
             <li data-slot="message-nav-item">
               <Switch>
                 <Match when={local.size === "compact"}>
-                  <div data-slot="message-nav-tick-button" data-active={message.id === local.current?.id || undefined}>
+                  <div
+                    data-slot="message-nav-tick-button"
+                    data-active={message.id === local.current?.id || undefined}
+                    role="button"
+                    tabindex={0}
+                    onClick={handleClick}
+                    onKeyDown={handleKeyPress}
+                  >
                     <div data-slot="message-nav-tick-line" />
                   </div>
                 </Match>
                 <Match when={local.size === "normal"}>
-                  <button data-slot="message-nav-message-button" onClick={handleClick}>
+                  <button data-slot="message-nav-message-button" onClick={handleClick} onKeyDown={handleKeyPress}>
                     <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
                     <div
                       data-slot="message-nav-title-preview"
                       data-active={message.id === local.current?.id || undefined}
                     >
-                      <Show when={message.summary?.title} fallback="New message">
-                        {message.summary?.title}
+                      <Show when={local.getLabel?.(message) ?? message.summary?.title} fallback="New message">
+                        {local.getLabel?.(message) ?? message.summary?.title}
                       </Show>
                     </div>
                   </button>

+ 0 - 44
packages/ui/src/components/session-message-rail.css

@@ -1,44 +0,0 @@
-[data-component="session-message-rail"] {
-  display: contents;
-}
-
-[data-slot="session-message-rail-compact"],
-[data-slot="session-message-rail-full"] {
-  position: absolute;
-  left: 1.5rem;
-  margin-top: 0.625rem;
-  top: 0;
-  bottom: 8rem;
-  overflow-y: auto;
-}
-
-[data-slot="session-message-rail-compact"] {
-  display: flex;
-}
-
-[data-slot="session-message-rail-full"] {
-  display: none;
-}
-
-@container (min-width: 88rem) {
-  [data-slot="session-message-rail-compact"] {
-    display: none;
-  }
-  [data-slot="session-message-rail-full"] {
-    display: flex;
-  }
-}
-
-[data-component="session-message-rail"] [data-slot="session-message-rail-full"] {
-  transform: none;
-}
-
-[data-component="session-message-rail"][data-wide] [data-slot="session-message-rail-full"] {
-  margin-top: 0.125rem;
-  left: calc(((100% - min(100%, 50rem)) / 2) - 1.5rem);
-  transform: translateX(-100%);
-}
-
-[data-component="session-message-rail"]:not([data-wide]) [data-slot="session-message-rail-full"] {
-  margin-top: 0.625rem;
-}

+ 0 - 46
packages/ui/src/components/session-message-rail.tsx

@@ -1,46 +0,0 @@
-import { UserMessage } from "@opencode-ai/sdk/v2"
-import { ComponentProps, Show, splitProps } from "solid-js"
-import { MessageNav } from "./message-nav"
-import "./session-message-rail.css"
-
-export interface SessionMessageRailProps extends ComponentProps<"div"> {
-  messages: UserMessage[]
-  current?: UserMessage
-  wide?: boolean
-  onMessageSelect: (message: UserMessage) => void
-}
-
-export function SessionMessageRail(props: SessionMessageRailProps) {
-  const [local, others] = splitProps(props, ["messages", "current", "wide", "onMessageSelect", "class", "classList"])
-
-  return (
-    <Show when={(local.messages?.length ?? 0) > 1}>
-      <div
-        {...others}
-        data-component="session-message-rail"
-        data-wide={local.wide ? "" : undefined}
-        classList={{
-          ...(local.classList ?? {}),
-          [local.class ?? ""]: !!local.class,
-        }}
-      >
-        <div data-slot="session-message-rail-compact">
-          <MessageNav
-            messages={local.messages}
-            current={local.current}
-            onMessageSelect={local.onMessageSelect}
-            size="compact"
-          />
-        </div>
-        <div data-slot="session-message-rail-full">
-          <MessageNav
-            messages={local.messages}
-            current={local.current}
-            onMessageSelect={local.onMessageSelect}
-            size={local.wide ? "normal" : "compact"}
-          />
-        </div>
-      </div>
-    </Show>
-  )
-}