فهرست منبع

wip(share): more styling

Adam 3 ماه پیش
والد
کامیت
6173b69a8b

+ 3 - 3
packages/desktop/src/context/session.tsx

@@ -5,7 +5,7 @@ import { useSync } from "./sync"
 import { makePersisted } from "@solid-primitives/storage"
 import { TextSelection } from "./local"
 import { pipe, sumBy } from "remeda"
-import { AssistantMessage } from "@opencode-ai/sdk"
+import { AssistantMessage, UserMessage } from "@opencode-ai/sdk"
 import { useParams } from "@solidjs/router"
 import { base64Encode } from "@/utils"
 
@@ -123,8 +123,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
         user: userMessages,
         last: lastUserMessage,
         active: activeMessage,
-        setActive(id: string | undefined) {
-          setStore("messageId", id)
+        setActive(message: UserMessage | undefined) {
+          setStore("messageId", message?.id)
         },
       },
       usage: {

+ 24 - 9
packages/desktop/src/pages/session.tsx

@@ -1,4 +1,4 @@
-import { For, onCleanup, onMount, Show, Match, Switch, createResource } from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js"
 import { useLocal, type LocalFile } from "@/context/local"
 import { createStore } from "solid-js/store"
 import { getDirectory, getFilename } from "@/utils"
@@ -12,7 +12,8 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
 import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { Code } from "@opencode-ai/ui/code"
-import { SessionTimeline } from "@opencode-ai/ui/session-timeline"
+import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import { MessageNav } from "@opencode-ai/ui/message-nav"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import { SelectDialog } from "@opencode-ai/ui/select-dialog"
 import {
@@ -255,6 +256,8 @@ export default function Page() {
     return typeof draggable.id === "string" ? draggable.id : undefined
   }
 
+  const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
+
   return (
     <div class="relative bg-background-base size-full overflow-x-hidden">
       <DragDropProvider
@@ -330,14 +333,26 @@ export default function Page() {
                 flex: layout.review.state() === "pane",
               }}
             >
-              <div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-xl mx-auto">
+              <div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-2xl mx-auto">
                 <Switch>
                   <Match when={session.id}>
-                    <SessionTimeline
-                      sessionID={session.id!}
-                      expanded={layout.review.state() === "tab" || !session.diffs().length}
-                      classes={{ root: "pb-20", container: "pb-20" }}
-                    />
+                    <div class="flex items-start justify-start h-full min-h-0">
+                      <Show when={session.messages.user().length > 1}>
+                        <MessageNav
+                          classList={{ "mt-1.5 mr-3": wide(), "mt-3 mr-8": !wide() }}
+                          messages={session.messages.user()}
+                          current={session.messages.active()}
+                          onMessageSelect={session.messages.setActive}
+                          size={wide() ? "normal" : "compact"}
+                          working={session.working()}
+                        />
+                      </Show>
+                      <SessionTurn
+                        sessionID={session.id!}
+                        messageID={session.messages.active()?.id!}
+                        classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20" }}
+                      />
+                    </div>
                   </Match>
                   <Match when={true}>
                     <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
@@ -456,7 +471,7 @@ export default function Page() {
         </DragOverlay>
       </DragDropProvider>
       <Show when={session.layout.tabs.active}>
-        <div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-6">
+        <div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
           <PromptInput
             ref={(el) => {
               inputRef = el

+ 42 - 16
packages/enterprise/src/routes/share/[sessionID].tsx

@@ -1,5 +1,5 @@
-import { FileDiff, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk"
-import { SessionTimeline } from "@opencode-ai/ui/session-timeline"
+import { FileDiff, Message, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk"
+import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import { DataProvider, useData } from "@opencode-ai/ui/context"
 import { createAsync, query, RouteDefinition, useParams } from "@solidjs/router"
@@ -10,6 +10,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { iife } from "@opencode-ai/util/iife"
 import { Binary } from "@opencode-ai/util/binary"
 import { DateTime } from "luxon"
+import { MessageNav } from "@opencode-ai/ui/message-nav"
+import { createStore } from "solid-js/store"
 
 const getData = query(async (sessionID) => {
   const data = await Share.data(sessionID)
@@ -40,7 +42,6 @@ const getData = query(async (sessionID) => {
     message: {},
     part: {},
   }
-
   for (const item of data) {
     switch (item.type) {
       case "session":
@@ -82,14 +83,28 @@ export default function () {
         <DataProvider data={data()}>
           {iife(() => {
             const data = useData()
+            const [store, setStore] = createStore({
+              messageId: undefined as string | undefined,
+            })
             const match = createMemo(() => Binary.search(data.session, params.sessionID!, (s) => s.id))
             if (!match().found) throw new Error(`Session ${params.sessionID} not found`)
             const info = createMemo(() => data.session[match().index])
-            const firstUserMessage = createMemo(() =>
-              data.message[params.sessionID!]?.filter((m) => m.role === "user")?.at(0),
+            const messages = createMemo(() =>
+              params.sessionID ? (data.message[params.sessionID]?.filter((m) => m.role === "user") ?? []) : [],
+            )
+            const firstUserMessage = createMemo(() => messages().at(0))
+            const activeMessage = createMemo(
+              () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
             )
-            const provider = createMemo(() => firstUserMessage()?.model?.providerID)
-            const model = createMemo(() => firstUserMessage()?.model?.modelID)
+            function setActiveMessage(message: UserMessage | undefined) {
+              if (message) {
+                setStore("messageId", message.id)
+              } else {
+                setStore("messageId", undefined)
+              }
+            }
+            const provider = createMemo(() => activeMessage()?.model?.providerID)
+            const model = createMemo(() => activeMessage()?.model?.modelID)
             const diffs = createMemo(() => data.session_diff[params.sessionID!] ?? [])
 
             return (
@@ -145,15 +160,26 @@ export default function () {
                         </div>
                         <div class="text-left text-16-medium text-text-strong">{info().title}</div>
                       </div>
-                      <SessionTimeline
-                        sessionID={params.sessionID!}
-                        classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
-                        expanded
-                      >
-                        <div class="flex items-center justify-center pb-8 shrink-0">
-                          <Logo class="w-58.5 opacity-12" />
-                        </div>
-                      </SessionTimeline>
+                      <div class="flex items-start justify-start h-full min-h-0">
+                        <Show when={messages().length > 1}>
+                          <MessageNav
+                            classList={{ "mt-3 mr-3": true }}
+                            messages={messages()}
+                            current={activeMessage()}
+                            onMessageSelect={setActiveMessage}
+                            size={!diffs().length ? "normal" : "compact"}
+                          />
+                        </Show>
+                        <SessionTurn
+                          sessionID={params.sessionID!}
+                          messageID={store.messageId ?? firstUserMessage()!.id!}
+                          classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
+                        >
+                          <div class="flex items-center justify-center pb-8 shrink-0">
+                            <Logo class="w-58.5 opacity-12" />
+                          </div>
+                        </SessionTurn>
+                      </div>
                     </div>
                     <Show when={diffs().length}>
                       <div class="relative grow px-6 pt-14 flex-1 min-h-0 border-l border-border-weak-base">

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

@@ -0,0 +1,95 @@
+[data-component="message-nav"] {
+  /* margin-right: 32px; */
+  /* margin-top: 12px; */
+  flex-shrink: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  padding-left: 0;
+  list-style: none;
+
+  &[data-size="normal"] {
+    position: absolute;
+    right: 100%;
+    width: 240px;
+    /* margin-top: 12px; */
+
+    @media (min-width: 80rem) {
+      gap: 8px;
+      /* margin-top: 4px; */
+    }
+  }
+}
+
+[data-slot="message-nav-item"] {
+  display: flex;
+  align-items: center;
+  align-self: stretch;
+  justify-content: flex-end;
+
+  [data-component="message-nav"][data-size="normal"] & {
+    justify-content: flex-start;
+  }
+}
+
+[data-slot="message-nav-tick-button"] {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  height: 8px;
+  width: 32px;
+  /* margin-right: -12px; */
+  cursor: pointer;
+  border: none;
+  background: none;
+  padding: 0;
+
+  &[data-active] [data-slot="message-nav-tick-line"] {
+    background-color: var(--icon-strong-base);
+    width: 100%;
+  }
+}
+
+[data-slot="message-nav-tick-line"] {
+  height: 1px;
+  width: 20px;
+  background-color: var(--icon-base);
+  transition:
+    width 0.2s,
+    background-color 0.2s;
+}
+
+[data-slot="message-nav-tick-button"]:hover [data-slot="message-nav-tick-line"] {
+  width: 100%;
+  background-color: var(--icon-strong-base);
+}
+
+[data-slot="message-nav-message-button"] {
+  display: flex;
+  align-items: center;
+  align-self: stretch;
+  width: 100%;
+  column-gap: 8px;
+  cursor: default;
+  border: none;
+  background: none;
+  padding: 0;
+}
+
+[data-slot="message-nav-title-preview"] {
+  font-size: 14px; /* text-14-regular */
+  color: var(--text-weak);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  min-width: 0;
+  text-align: left;
+
+  &[data-active] {
+    color: var(--text-strong);
+  }
+}
+
+[data-slot="message-nav-item"]:hover [data-slot="message-nav-title-preview"] {
+  color: var(--text-base);
+}

+ 66 - 0
packages/ui/src/components/message-nav.tsx

@@ -0,0 +1,66 @@
+import { UserMessage } from "@opencode-ai/sdk"
+import { ComponentProps, createMemo, For, Match, Show, splitProps, Switch } from "solid-js"
+import { DiffChanges } from "./diff-changes"
+import { Spinner } from "./spinner"
+
+export function MessageNav(
+  props: ComponentProps<"ul"> & {
+    messages: UserMessage[]
+    current?: UserMessage
+    size: "normal" | "compact"
+    working?: boolean
+    onMessageSelect: (message: UserMessage) => void
+  },
+) {
+  const [local, others] = splitProps(props, ["messages", "current", "size", "working", "onMessageSelect"])
+  const lastUserMessage = createMemo(() => {
+    return local.messages?.at(0)
+  })
+
+  return (
+    <ul role="list" data-component="message-nav" data-size={local.size} {...others}>
+      <For each={local.messages}>
+        {(message) => {
+          const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && local.working)
+          const handleClick = () => local.onMessageSelect(message)
+
+          return (
+            <li data-slot="message-nav-item">
+              <Switch>
+                <Match when={local.size === "compact"}>
+                  <button
+                    data-slot="message-nav-tick-button"
+                    data-active={message.id === local.current?.id || undefined}
+                    onClick={handleClick}
+                  >
+                    <div data-slot="message-nav-tick-line" />
+                  </button>
+                </Match>
+                <Match when={local.size === "normal"}>
+                  <button data-slot="message-nav-message-button" onClick={handleClick}>
+                    <Switch>
+                      <Match when={messageWorking()}>
+                        <Spinner />
+                      </Match>
+                      <Match when={true}>
+                        <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
+                      </Match>
+                    </Switch>
+                    <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>
+                    </div>
+                  </button>
+                </Match>
+              </Switch>
+            </li>
+          )
+        }}
+      </For>
+    </ul>
+  )
+}

+ 0 - 1
packages/ui/src/components/message-progress.tsx

@@ -3,7 +3,6 @@ import { Part } from "./message-part"
 import { Spinner } from "./spinner"
 import { useData } from "../context/data"
 import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk"
-import "./message-progress.css"
 
 export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
   const data = useData()

+ 0 - 324
packages/ui/src/components/session-timeline.css

@@ -1,324 +0,0 @@
-[data-component="session-timeline"] {
-  /* flex: 1; */
-  min-height: 0;
-  display: flex;
-  align-items: flex-start;
-  justify-content: flex-start;
-
-  [data-slot="session-timeline-timeline-list"] {
-    margin-right: 32px;
-    flex-shrink: 0;
-    display: flex;
-    flex-direction: column;
-    align-items: flex-start;
-    margin-top: 12px;
-
-    &[data-expanded="true"] {
-      position: absolute;
-      right: 100%;
-      width: 240px;
-      margin-top: 12px;
-
-      @media (min-width: 80rem) {
-        gap: 8px;
-        margin-top: 4px;
-      }
-    }
-  }
-
-  [data-slot="session-timeline-timeline-item"] {
-    display: flex;
-    align-items: center;
-    align-self: stretch;
-    justify-content: flex-end;
-
-    &[data-expanded="true"] {
-      @media (min-width: 80rem) {
-        justify-content: flex-start;
-      }
-    }
-  }
-
-  [data-slot="session-timeline-tick-button"] {
-    display: flex;
-    align-items: center;
-    justify-content: flex-start;
-    height: 8px;
-    width: 32px;
-    margin-right: -12px;
-    cursor: pointer;
-    border: none;
-    background: none;
-    padding: 0;
-
-    &[data-active="true"] [data-slot="session-timeline-tick-line"] {
-      background-color: var(--icon-strong-base);
-      width: 100%;
-    }
-
-    &[data-expanded="true"] {
-      @media (min-width: 80rem) {
-        display: none;
-      }
-    }
-  }
-
-  [data-slot="session-timeline-tick-line"] {
-    height: 1px;
-    width: 20px;
-    background-color: var(--icon-base);
-    transition:
-      width 0.2s,
-      background-color 0.2s;
-  }
-
-  [data-slot="session-timeline-tick-button"]:hover [data-slot="session-timeline-tick-line"] {
-    width: 100%;
-    background-color: var(--icon-strong-base);
-  }
-
-  [data-slot="session-timeline-message-button"] {
-    display: none;
-    align-items: center;
-    align-self: stretch;
-    width: 100%;
-    column-gap: 8px;
-    cursor: default;
-    border: none;
-    background: none;
-    padding: 0;
-
-    &[data-expanded="true"] {
-      @media (min-width: 80rem) {
-        display: flex;
-      }
-    }
-  }
-
-  [data-slot="session-timeline-message-title-preview"] {
-    font-size: 14px; /* text-14-regular */
-    color: var(--text-weak);
-    white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
-    min-width: 0;
-    text-align: left;
-
-    &[data-active="true"] {
-      color: var(--text-strong);
-    }
-  }
-
-  [data-slot="session-timeline-timeline-item"]:hover [data-slot="session-timeline-message-title-preview"] {
-    color: var(--text-base);
-  }
-
-  [data-slot="session-timeline-content"] {
-    flex-grow: 1;
-    width: 100%;
-    height: 100%;
-    min-width: 0;
-    overflow-y: auto;
-    scrollbar-width: none;
-  }
-
-  [data-slot="session-timeline-content"]::-webkit-scrollbar {
-    display: none;
-  }
-
-  [data-slot="session-timeline-message-container"] {
-    display: flex;
-    flex-direction: column;
-    align-items: flex-start;
-    align-self: stretch;
-    gap: 32px;
-  }
-
-  [data-slot="session-timeline-message-header"] {
-    display: flex;
-    align-items: center;
-    gap: 8px;
-    align-self: stretch;
-    position: sticky;
-    top: 0;
-    background-color: var(--background-stronger);
-    z-index: 20;
-    height: 32px;
-  }
-
-  [data-slot="session-timeline-message-content"] {
-    margin-top: -24px;
-  }
-
-  [data-slot="session-timeline-message-title"] {
-    width: 100%;
-    font-size: 14px; /* text-14-medium */
-    font-weight: 500;
-    color: var(--text-strong);
-    overflow: hidden;
-    text-overflow: ellipsis;
-    min-width: 0;
-    white-space: nowrap;
-  }
-
-  [data-slot="session-timeline-message-title"] h1 {
-    overflow: hidden;
-    text-overflow: ellipsis;
-    min-width: 0;
-    white-space: nowrap;
-    font-size: inherit;
-    font-weight: inherit;
-  }
-
-  [data-slot="session-timeline-typewriter"] {
-    overflow: hidden;
-    text-overflow: ellipsis;
-    min-width: 0;
-    white-space: nowrap;
-  }
-
-  [data-slot="session-timeline-summary-section"] {
-    width: 100%;
-    display: flex;
-    flex-direction: column;
-    gap: 24px;
-    align-items: flex-start;
-    align-self: stretch;
-  }
-
-  [data-slot="session-timeline-summary-header"] {
-    display: flex;
-    flex-direction: column;
-    align-items: flex-start;
-    gap: 4px;
-    align-self: stretch;
-  }
-
-  [data-slot="session-timeline-summary-title"] {
-    font-size: 12px; /* text-12-medium */
-    font-weight: 500;
-    color: var(--text-weak);
-  }
-
-  [data-slot="session-timeline-markdown"] {
-    &[data-diffs="true"] {
-      font-size: 14px; /* text-14-regular */
-    }
-
-    &[data-fade="true"] > * {
-      animation: fade-up-text 0.3s ease-out forwards;
-    }
-  }
-
-  [data-slot="session-timeline-accordion"] {
-    width: 100%;
-  }
-
-  [data-component="sticky-accordion-header"] {
-    top: 40px;
-
-    &[data-expanded]::before {
-      top: -40px;
-    }
-  }
-
-  [data-slot="session-timeline-accordion-trigger-content"] {
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    width: 100%;
-    gap: 20px;
-  }
-
-  [data-slot="session-timeline-file-info"] {
-    flex-grow: 1;
-    display: flex;
-    align-items: center;
-    gap: 20px;
-    min-width: 0;
-  }
-
-  [data-slot="session-timeline-file-icon"] {
-    flex-shrink: 0;
-    width: 16px;
-    height: 16px;
-  }
-
-  [data-slot="session-timeline-file-path"] {
-    display: flex;
-    flex-grow: 1;
-    min-width: 0;
-  }
-
-  [data-slot="session-timeline-directory"] {
-    color: var(--text-base);
-    text-overflow: ellipsis;
-    overflow: hidden;
-    white-space: nowrap;
-    direction: rtl;
-    text-align: left;
-  }
-
-  [data-slot="session-timeline-filename"] {
-    color: var(--text-strong);
-    flex-shrink: 0;
-  }
-
-  [data-slot="session-timeline-accordion-actions"] {
-    flex-shrink: 0;
-    display: flex;
-    gap: 16px;
-    align-items: center;
-    justify-content: flex-end;
-  }
-
-  [data-slot="session-timeline-accordion-content"] {
-    max-height: 240px; /* max-h-60 */
-    overflow-y: auto;
-    scrollbar-width: none;
-  }
-
-  [data-slot="session-timeline-accordion-content"]::-webkit-scrollbar {
-    display: none;
-  }
-
-  [data-slot="session-timeline-response-section"] {
-    width: 100%;
-  }
-
-  [data-slot="session-timeline-collapsible-trigger-content"] {
-    color: var(--text-weak);
-    cursor: pointer;
-    background: none;
-    border: none;
-    padding: 0;
-    display: flex;
-    align-items: center;
-
-    &:hover {
-      color: var(--text-strong);
-    }
-    display: flex;
-    align-items: center;
-    gap: 4px;
-    align-self: stretch;
-  }
-
-  [data-slot="session-timeline-details-text"] {
-    font-size: 12px; /* text-12-medium */
-    font-weight: 500;
-  }
-
-  .error-card {
-    color: var(--text-on-critical-base);
-  }
-
-  [data-slot="session-timeline-collapsible-content-inner"] {
-    width: 100%;
-    display: flex;
-    flex-direction: column;
-    align-items: flex-start;
-    align-self: stretch;
-    gap: 12px;
-  }
-}

+ 0 - 289
packages/ui/src/components/session-timeline.tsx

@@ -1,289 +0,0 @@
-import { AssistantMessage } from "@opencode-ai/sdk"
-import { useData } from "../context"
-import { Binary } from "@opencode-ai/util/binary"
-import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch } from "solid-js"
-import { createStore } from "solid-js/store"
-import { DiffChanges } from "./diff-changes"
-import { Spinner } from "./spinner"
-import { Typewriter } from "./typewriter"
-import { Message } from "./message-part"
-import { Markdown } from "./markdown"
-import { Accordion } from "./accordion"
-import { StickyAccordionHeader } from "./sticky-accordion-header"
-import { FileIcon } from "./file-icon"
-import { Icon } from "./icon"
-import { Diff } from "./diff"
-import { Card } from "./card"
-import { MessageProgress } from "./message-progress"
-import { Collapsible } from "./collapsible"
-
-export function SessionTimeline(
-  props: ParentProps<{
-    sessionID: string
-    classes?: {
-      root?: string
-      content?: string
-      container?: string
-    }
-    expanded?: boolean
-  }>,
-) {
-  const data = useData()
-  const [store, setStore] = createStore({
-    messageId: undefined as string | undefined,
-  })
-  const match = Binary.search(data.session, props.sessionID, (s) => s.id)
-  if (!match.found) throw new Error(`Session ${props.sessionID} not found`)
-
-  // const info = createMemo(() => data.session[match.index])
-  const messages = createMemo(() => (props.sessionID ? (data.message[props.sessionID] ?? []) : []))
-  const userMessages = createMemo(() =>
-    messages()
-      .filter((m) => m.role === "user")
-      .sort((a, b) => b.id.localeCompare(a.id)),
-  )
-  const lastUserMessage = createMemo(() => {
-    return userMessages()?.at(0)
-  })
-  const activeMessage = createMemo(() => {
-    if (!store.messageId) return lastUserMessage()
-    return userMessages()?.find((m) => m.id === store.messageId)
-  })
-  const status = createMemo(
-    () =>
-      data.session_status[props.sessionID] ?? {
-        type: "idle",
-      },
-  )
-  const working = createMemo(() => status()?.type !== "idle")
-
-  return (
-    <div data-component="session-timeline" class={props.classes?.root}>
-      <Show when={userMessages().length > 1}>
-        <ul role="list" data-slot="session-timeline-timeline-list" data-expanded={props.expanded}>
-          <For each={userMessages()}>
-            {(message) => {
-              const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && working())
-              const handleClick = () => setStore("messageId", message.id)
-
-              return (
-                <li data-slot="session-timeline-timeline-item" data-expanded={props.expanded}>
-                  <button
-                    data-slot="session-timeline-tick-button"
-                    data-active={activeMessage()?.id === message.id}
-                    data-expanded={props.expanded}
-                    onClick={handleClick}
-                  >
-                    <div data-slot="session-timeline-tick-line" />
-                  </button>
-                  <button
-                    data-slot="session-timeline-message-button"
-                    data-expanded={props.expanded}
-                    onClick={handleClick}
-                  >
-                    <Switch>
-                      <Match when={messageWorking()}>
-                        <Spinner />
-                      </Match>
-                      <Match when={true}>
-                        <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
-                      </Match>
-                    </Switch>
-                    <div
-                      data-slot="session-timeline-message-title-preview"
-                      data-active={activeMessage()?.id === message.id}
-                    >
-                      <Show when={message.summary?.title} fallback="New message">
-                        {message.summary?.title}
-                      </Show>
-                    </div>
-                  </button>
-                </li>
-              )
-            }}
-          </For>
-        </ul>
-      </Show>
-      <div data-slot="session-timeline-content" class={props.classes?.content}>
-        <For each={userMessages()}>
-          {(message) => {
-            const isActive = createMemo(() => activeMessage()?.id === message.id)
-            const titleSeen = createMemo(() => true)
-            const contentSeen = createMemo(() => true)
-            {
-              /* const titleSeen = createSeen(`message-title-${message.id}`) */
-            }
-            {
-              /* const contentSeen = createSeen(`message-content-${message.id}`) */
-            }
-            const [titled, setTitled] = createSignal(titleSeen())
-            const assistantMessages = createMemo(() => {
-              return messages()?.filter((m) => m.role === "assistant" && m.parentID == message.id) as AssistantMessage[]
-            })
-            const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
-            const [detailsExpanded, setDetailsExpanded] = createSignal(false)
-            const parts = createMemo(() => data.part[message.id])
-            const hasToolPart = createMemo(() =>
-              assistantMessages()
-                ?.flatMap((m) => data.part[m.id])
-                .some((p) => p?.type === "tool"),
-            )
-            const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && working())
-            const initialCompleted = !(message.id === lastUserMessage()?.id && working())
-            const [completed, setCompleted] = createSignal(initialCompleted)
-
-            // allowing time for the animations to finish
-            createEffect(() => {
-              if (titleSeen()) return
-              const title = message.summary?.title
-              if (title) setTimeout(() => setTitled(true), 10_000)
-            })
-            createEffect(() => {
-              const completed = !messageWorking()
-              setTimeout(() => setCompleted(completed), 1200)
-            })
-
-            return (
-              <Show when={isActive()}>
-                <div
-                  data-message={message.id}
-                  data-slot="session-timeline-message-container"
-                  class={props.classes?.container}
-                >
-                  {/* Title */}
-                  <div data-slot="session-timeline-message-header">
-                    <div data-slot="session-timeline-message-title">
-                      <Show
-                        when={titled()}
-                        fallback={
-                          <Typewriter as="h1" text={message.summary?.title} data-slot="session-timeline-typewriter" />
-                        }
-                      >
-                        <h1>{message.summary?.title}</h1>
-                      </Show>
-                    </div>
-                  </div>
-                  <div data-slot="session-timeline-message-content">
-                    <Message message={message} parts={parts()} />
-                  </div>
-                  {/* Summary */}
-                  <Show when={completed()}>
-                    <div data-slot="session-timeline-summary-section">
-                      <div data-slot="session-timeline-summary-header">
-                        <h2 data-slot="session-timeline-summary-title">
-                          <Switch>
-                            <Match when={message.summary?.diffs?.length}>Summary</Match>
-                            <Match when={true}>Response</Match>
-                          </Switch>
-                        </h2>
-                        <Show when={message.summary?.body}>
-                          {(summary) => (
-                            <Markdown
-                              data-slot="session-timeline-markdown"
-                              data-diffs={!!message.summary?.diffs?.length}
-                              data-fade={!message.summary?.diffs?.length && !contentSeen()}
-                              text={summary()}
-                            />
-                          )}
-                        </Show>
-                      </div>
-                      <Accordion data-slot="session-timeline-accordion" multiple>
-                        <For each={message.summary?.diffs ?? []}>
-                          {(diff) => (
-                            <Accordion.Item value={diff.file}>
-                              <StickyAccordionHeader>
-                                <Accordion.Trigger>
-                                  <div data-slot="session-timeline-accordion-trigger-content">
-                                    <div data-slot="session-timeline-file-info">
-                                      <FileIcon
-                                        node={{ path: diff.file, type: "file" }}
-                                        data-slot="session-timeline-file-icon"
-                                      />
-                                      <div data-slot="session-timeline-file-path">
-                                        <Show when={diff.file.includes("/")}>
-                                          <span data-slot="session-timeline-directory">
-                                            {getDirectory(diff.file)}&lrm;
-                                          </span>
-                                        </Show>
-                                        <span data-slot="session-timeline-filename">{getFilename(diff.file)}</span>
-                                      </div>
-                                    </div>
-                                    <div data-slot="session-timeline-accordion-actions">
-                                      <DiffChanges changes={diff} />
-                                      <Icon name="chevron-grabber-vertical" size="small" />
-                                    </div>
-                                  </div>
-                                </Accordion.Trigger>
-                              </StickyAccordionHeader>
-                              <Accordion.Content data-slot="session-timeline-accordion-content">
-                                <Diff
-                                  before={{
-                                    name: diff.file!,
-                                    contents: diff.before!,
-                                  }}
-                                  after={{
-                                    name: diff.file!,
-                                    contents: diff.after!,
-                                  }}
-                                />
-                              </Accordion.Content>
-                            </Accordion.Item>
-                          )}
-                        </For>
-                      </Accordion>
-                    </div>
-                  </Show>
-                  <Show when={error() && !detailsExpanded()}>
-                    <Card variant="error" class="error-card">
-                      {error()?.data?.message as string}
-                    </Card>
-                  </Show>
-                  {/* Response */}
-                  <div data-slot="session-timeline-response-section">
-                    <Switch>
-                      <Match when={!completed()}>
-                        <MessageProgress assistantMessages={assistantMessages} done={!messageWorking()} />
-                      </Match>
-                      <Match when={completed() && hasToolPart()}>
-                        <Collapsible variant="ghost" open={detailsExpanded()} onOpenChange={setDetailsExpanded}>
-                          <Collapsible.Trigger>
-                            <div data-slot="session-timeline-collapsible-trigger-content">
-                              <div data-slot="session-timeline-details-text">
-                                <Switch>
-                                  <Match when={detailsExpanded()}>Hide details</Match>
-                                  <Match when={!detailsExpanded()}>Show details</Match>
-                                </Switch>
-                              </div>
-                              <Collapsible.Arrow />
-                            </div>
-                          </Collapsible.Trigger>
-                          <Collapsible.Content>
-                            <div data-slot="session-timeline-collapsible-content-inner">
-                              <For each={assistantMessages()}>
-                                {(assistantMessage) => {
-                                  const parts = createMemo(() => data.part[assistantMessage.id])
-                                  return <Message message={assistantMessage} parts={parts()} />
-                                }}
-                              </For>
-                              <Show when={error()}>
-                                <Card variant="error" class="error-card">
-                                  {error()?.data?.message as string}
-                                </Card>
-                              </Show>
-                            </div>
-                          </Collapsible.Content>
-                        </Collapsible>
-                      </Match>
-                    </Switch>
-                  </div>
-                </div>
-              </Show>
-            )
-          }}
-        </For>
-        {props.children}
-      </div>
-    </div>
-  )
-}

+ 220 - 0
packages/ui/src/components/session-turn.css

@@ -0,0 +1,220 @@
+[data-component="session-turn"] {
+  /* flex: 1; */
+  height: 100%;
+  min-height: 0;
+  min-width: 0;
+  display: flex;
+  align-items: flex-start;
+  justify-content: flex-start;
+
+  [data-slot="session-turn-content"] {
+    flex-grow: 1;
+    width: 100%;
+    height: 100%;
+    min-width: 0;
+    overflow-y: auto;
+    scrollbar-width: none;
+  }
+
+  [data-slot="session-turn-content"]::-webkit-scrollbar {
+    display: none;
+  }
+
+  [data-slot="session-turn-message-container"] {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    align-self: stretch;
+    min-width: 0;
+    gap: 32px;
+  }
+
+  [data-slot="session-turn-message-header"] {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    align-self: stretch;
+    position: sticky;
+    top: 0;
+    background-color: var(--background-stronger);
+    z-index: 20;
+    height: 32px;
+  }
+
+  [data-slot="session-turn-message-content"] {
+    margin-top: -24px;
+  }
+
+  [data-slot="session-turn-message-title"] {
+    width: 100%;
+    font-size: 14px; /* text-14-medium */
+    font-weight: 500;
+    color: var(--text-strong);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    min-width: 0;
+    white-space: nowrap;
+  }
+
+  [data-slot="session-turn-message-title"] h1 {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    min-width: 0;
+    white-space: nowrap;
+    font-size: inherit;
+    font-weight: inherit;
+  }
+
+  [data-slot="session-turn-typewriter"] {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    min-width: 0;
+    white-space: nowrap;
+  }
+
+  [data-slot="session-turn-summary-section"] {
+    width: 100%;
+    display: flex;
+    flex-direction: column;
+    gap: 24px;
+    align-items: flex-start;
+    align-self: stretch;
+  }
+
+  [data-slot="session-turn-summary-header"] {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 4px;
+    align-self: stretch;
+  }
+
+  [data-slot="session-turn-summary-title"] {
+    font-size: 12px; /* text-12-medium */
+    font-weight: 500;
+    color: var(--text-weak);
+  }
+
+  [data-slot="session-turn-markdown"] {
+    &[data-diffs="true"] {
+      font-size: 14px; /* text-14-regular */
+    }
+
+    &[data-fade="true"] > * {
+      animation: fade-up-text 0.3s ease-out forwards;
+    }
+  }
+
+  [data-slot="session-turn-accordion"] {
+    width: 100%;
+  }
+
+  [data-component="sticky-accordion-header"] {
+    top: 40px;
+
+    &[data-expanded]::before {
+      top: -40px;
+    }
+  }
+
+  [data-slot="session-turn-accordion-trigger-content"] {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    width: 100%;
+    gap: 20px;
+  }
+
+  [data-slot="session-turn-file-info"] {
+    flex-grow: 1;
+    display: flex;
+    align-items: center;
+    gap: 20px;
+    min-width: 0;
+  }
+
+  [data-slot="session-turn-file-icon"] {
+    flex-shrink: 0;
+    width: 16px;
+    height: 16px;
+  }
+
+  [data-slot="session-turn-file-path"] {
+    display: flex;
+    flex-grow: 1;
+    min-width: 0;
+  }
+
+  [data-slot="session-turn-directory"] {
+    color: var(--text-base);
+    text-overflow: ellipsis;
+    overflow: hidden;
+    white-space: nowrap;
+    direction: rtl;
+    text-align: left;
+  }
+
+  [data-slot="session-turn-filename"] {
+    color: var(--text-strong);
+    flex-shrink: 0;
+  }
+
+  [data-slot="session-turn-accordion-actions"] {
+    flex-shrink: 0;
+    display: flex;
+    gap: 16px;
+    align-items: center;
+    justify-content: flex-end;
+  }
+
+  [data-slot="session-turn-accordion-content"] {
+    max-height: 240px; /* max-h-60 */
+    overflow-y: auto;
+    scrollbar-width: none;
+  }
+
+  [data-slot="session-turn-accordion-content"]::-webkit-scrollbar {
+    display: none;
+  }
+
+  [data-slot="session-turn-response-section"] {
+    width: 100%;
+    min-width: 0;
+  }
+
+  [data-slot="session-turn-collapsible-trigger-content"] {
+    color: var(--text-weak);
+    cursor: pointer;
+    background: none;
+    border: none;
+    padding: 0;
+    display: flex;
+    align-items: center;
+
+    &:hover {
+      color: var(--text-strong);
+    }
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    align-self: stretch;
+  }
+
+  [data-slot="session-turn-details-text"] {
+    font-size: 12px; /* text-12-medium */
+    font-weight: 500;
+  }
+
+  .error-card {
+    color: var(--text-on-critical-base);
+  }
+
+  [data-slot="session-turn-collapsible-content-inner"] {
+    width: 100%;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    align-self: stretch;
+    gap: 12px;
+  }
+}

+ 220 - 0
packages/ui/src/components/session-turn.tsx

@@ -0,0 +1,220 @@
+import { AssistantMessage } from "@opencode-ai/sdk"
+import { useData } from "../context"
+import { Binary } from "@opencode-ai/util/binary"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch } from "solid-js"
+import { DiffChanges } from "./diff-changes"
+import { Typewriter } from "./typewriter"
+import { Message } from "./message-part"
+import { Markdown } from "./markdown"
+import { Accordion } from "./accordion"
+import { StickyAccordionHeader } from "./sticky-accordion-header"
+import { FileIcon } from "./file-icon"
+import { Icon } from "./icon"
+import { Diff } from "./diff"
+import { Card } from "./card"
+import { MessageProgress } from "./message-progress"
+import { Collapsible } from "./collapsible"
+
+export function SessionTurn(
+  props: ParentProps<{
+    sessionID: string
+    messageID: string
+    classes?: {
+      root?: string
+      content?: string
+      container?: string
+    }
+  }>,
+) {
+  const data = useData()
+  const match = Binary.search(data.session, props.sessionID, (s) => s.id)
+  if (!match.found) throw new Error(`Session ${props.sessionID} not found`)
+
+  const messages = createMemo(() => (props.sessionID ? (data.message[props.sessionID] ?? []) : []))
+  const userMessages = createMemo(() =>
+    messages()
+      .filter((m) => m.role === "user")
+      .sort((a, b) => b.id.localeCompare(a.id)),
+  )
+  const lastUserMessage = createMemo(() => {
+    return userMessages()?.at(0)
+  })
+  const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
+
+  const status = createMemo(
+    () =>
+      data.session_status[props.sessionID] ?? {
+        type: "idle",
+      },
+  )
+  const working = createMemo(() => status()?.type !== "idle")
+
+  return (
+    <div data-component="session-turn" class={props.classes?.root}>
+      <div data-slot="session-turn-content" class={props.classes?.content}>
+        <Show when={message()}>
+          {(msg) => {
+            const titleSeen = createMemo(() => true)
+            const contentSeen = createMemo(() => true)
+
+            const [titled, setTitled] = createSignal(titleSeen())
+            const assistantMessages = createMemo(() => {
+              return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[]
+            })
+            const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
+            const [detailsExpanded, setDetailsExpanded] = createSignal(false)
+            const parts = createMemo(() => data.part[msg().id])
+            const hasToolPart = createMemo(() =>
+              assistantMessages()
+                ?.flatMap((m) => data.part[m.id])
+                .some((p) => p?.type === "tool"),
+            )
+            const messageWorking = createMemo(() => msg().id === lastUserMessage()?.id && working())
+            const initialCompleted = !(msg().id === lastUserMessage()?.id && working())
+            const [completed, setCompleted] = createSignal(initialCompleted)
+
+            // allowing time for the animations to finish
+            createEffect(() => {
+              if (titleSeen()) return
+              const title = msg().summary?.title
+              if (title) setTimeout(() => setTitled(true), 10_000)
+            })
+            createEffect(() => {
+              const completed = !messageWorking()
+              setTimeout(() => setCompleted(completed), 1200)
+            })
+
+            return (
+              <div data-message={msg().id} data-slot="session-turn-message-container" class={props.classes?.container}>
+                {/* Title */}
+                <div data-slot="session-turn-message-header">
+                  <div data-slot="session-turn-message-title">
+                    <Show
+                      when={titled()}
+                      fallback={<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />}
+                    >
+                      <h1>{msg().summary?.title}</h1>
+                    </Show>
+                  </div>
+                </div>
+                <div data-slot="session-turn-message-content">
+                  <Message message={msg()} parts={parts()} />
+                </div>
+                {/* Summary */}
+                <Show when={completed()}>
+                  <div data-slot="session-turn-summary-section">
+                    <div data-slot="session-turn-summary-header">
+                      <h2 data-slot="session-turn-summary-title">
+                        <Switch>
+                          <Match when={msg().summary?.diffs?.length}>Summary</Match>
+                          <Match when={true}>Response</Match>
+                        </Switch>
+                      </h2>
+                      <Show when={msg().summary?.body}>
+                        {(summary) => (
+                          <Markdown
+                            data-slot="session-turn-markdown"
+                            data-diffs={!!msg().summary?.diffs?.length}
+                            data-fade={!msg().summary?.diffs?.length && !contentSeen()}
+                            text={summary()}
+                          />
+                        )}
+                      </Show>
+                    </div>
+                    <Accordion data-slot="session-turn-accordion" multiple>
+                      <For each={msg().summary?.diffs ?? []}>
+                        {(diff) => (
+                          <Accordion.Item value={diff.file}>
+                            <StickyAccordionHeader>
+                              <Accordion.Trigger>
+                                <div data-slot="session-turn-accordion-trigger-content">
+                                  <div data-slot="session-turn-file-info">
+                                    <FileIcon
+                                      node={{ path: diff.file, type: "file" }}
+                                      data-slot="session-turn-file-icon"
+                                    />
+                                    <div data-slot="session-turn-file-path">
+                                      <Show when={diff.file.includes("/")}>
+                                        <span data-slot="session-turn-directory">{getDirectory(diff.file)}&lrm;</span>
+                                      </Show>
+                                      <span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
+                                    </div>
+                                  </div>
+                                  <div data-slot="session-turn-accordion-actions">
+                                    <DiffChanges changes={diff} />
+                                    <Icon name="chevron-grabber-vertical" size="small" />
+                                  </div>
+                                </div>
+                              </Accordion.Trigger>
+                            </StickyAccordionHeader>
+                            <Accordion.Content data-slot="session-turn-accordion-content">
+                              <Diff
+                                before={{
+                                  name: diff.file!,
+                                  contents: diff.before!,
+                                }}
+                                after={{
+                                  name: diff.file!,
+                                  contents: diff.after!,
+                                }}
+                              />
+                            </Accordion.Content>
+                          </Accordion.Item>
+                        )}
+                      </For>
+                    </Accordion>
+                  </div>
+                </Show>
+                <Show when={error() && !detailsExpanded()}>
+                  <Card variant="error" class="error-card">
+                    {error()?.data?.message as string}
+                  </Card>
+                </Show>
+                {/* Response */}
+                <div data-slot="session-turn-response-section">
+                  <Switch>
+                    <Match when={!completed()}>
+                      <MessageProgress assistantMessages={assistantMessages} done={!messageWorking()} />
+                    </Match>
+                    <Match when={completed() && hasToolPart()}>
+                      <Collapsible variant="ghost" open={detailsExpanded()} onOpenChange={setDetailsExpanded}>
+                        <Collapsible.Trigger>
+                          <div data-slot="session-turn-collapsible-trigger-content">
+                            <div data-slot="session-turn-details-text">
+                              <Switch>
+                                <Match when={detailsExpanded()}>Hide details</Match>
+                                <Match when={!detailsExpanded()}>Show details</Match>
+                              </Switch>
+                            </div>
+                            <Collapsible.Arrow />
+                          </div>
+                        </Collapsible.Trigger>
+                        <Collapsible.Content>
+                          <div data-slot="session-turn-collapsible-content-inner">
+                            <For each={assistantMessages()}>
+                              {(assistantMessage) => {
+                                const parts = createMemo(() => data.part[assistantMessage.id])
+                                return <Message message={assistantMessage} parts={parts()} />
+                              }}
+                            </For>
+                            <Show when={error()}>
+                              <Card variant="error" class="error-card">
+                                {error()?.data?.message as string}
+                              </Card>
+                            </Show>
+                          </div>
+                        </Collapsible.Content>
+                      </Collapsible>
+                    </Match>
+                  </Switch>
+                </div>
+              </div>
+            )
+          }}
+        </Show>
+        {props.children}
+      </div>
+    </div>
+  )
+}

+ 3 - 1
packages/ui/src/styles/index.css

@@ -22,12 +22,14 @@
 @import "../components/logo.css" layer(components);
 @import "../components/markdown.css" layer(components);
 @import "../components/message-part.css" layer(components);
+@import "../components/message-progress.css" layer(components);
+@import "../components/message-nav.css" layer(components);
 @import "../components/progress-circle.css" layer(components);
 @import "../components/select.css" layer(components);
 @import "../components/select-dialog.css" layer(components);
 @import "../components/spinner.css" layer(components);
 @import "../components/session-review.css" layer(components);
-@import "../components/session-timeline.css" layer(components);
+@import "../components/session-turn.css" layer(components);
 @import "../components/sticky-accordion-header.css" layer(components);
 @import "../components/tabs.css" layer(components);
 @import "../components/tooltip.css" layer(components);