Browse Source

wip: desktop timeline changes

Adam 2 months ago
parent
commit
ad008d2151

+ 1 - 0
bun.lock

@@ -398,6 +398,7 @@
         "@tailwindcss/vite": "catalog:",
         "@tsconfig/node22": "catalog:",
         "@types/bun": "catalog:",
+        "@types/luxon": "catalog:",
         "tailwindcss": "catalog:",
         "typescript": "catalog:",
         "vite": "catalog:",

+ 1 - 0
packages/ui/package.json

@@ -22,6 +22,7 @@
   },
   "devDependencies": {
     "@types/bun": "catalog:",
+    "@types/luxon": "catalog:",
     "@tsconfig/node22": "catalog:",
     "typescript": "catalog:",
     "vite": "catalog:",

+ 20 - 0
packages/ui/src/components/button.css

@@ -100,6 +100,26 @@
     }
   }
 
+  &[data-size="small"] {
+    height: 22px;
+    padding: 0 8px;
+    &[data-icon] {
+      padding: 0 12px 0 4px;
+    }
+
+    font-size: var(--font-size-small);
+    line-height: var(--line-height-large);
+    gap: 4px;
+
+    /* text-12-medium */
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    font-style: normal;
+    font-weight: var(--font-weight-medium);
+    line-height: var(--line-height-large); /* 166.667% */
+    letter-spacing: var(--letter-spacing-normal);
+  }
+
   &[data-size="normal"] {
     height: 24px;
     padding: 0 6px;

+ 1 - 1
packages/ui/src/components/button.tsx

@@ -5,7 +5,7 @@ import { Icon, IconProps } from "./icon"
 export interface ButtonProps
   extends ComponentProps<typeof Kobalte>,
     Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
-  size?: "normal" | "large"
+  size?: "small" | "normal" | "large"
   variant?: "primary" | "secondary" | "ghost"
   icon?: IconProps["name"]
 }

+ 10 - 0
packages/ui/src/components/message-part.css

@@ -29,6 +29,16 @@
   }
 }
 
+[data-component="reasoning-part"] {
+  width: 100%;
+  opacity: 0.5;
+
+  [data-component="markdown"] {
+    margin-top: 24px;
+    font-style: italic !important;
+  }
+}
+
 [data-component="tool-error"] {
   display: flex;
   align-items: start;

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

@@ -18,7 +18,6 @@ import { DiffChanges } from "./diff-changes"
 import { Markdown } from "./markdown"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { sanitizePart } from "@opencode-ai/util/sanitize"
-import { unwrap } from "solid-js/store"
 
 export interface MessageProps {
   message: MessageType
@@ -63,7 +62,6 @@ export function Message(props: MessageProps) {
 export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[]; sanitize?: RegExp }) {
   const filteredParts = createMemo(() => {
     return props.parts?.filter((x) => {
-      if (x.type === "reasoning") return false
       return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
     })
   })
@@ -84,7 +82,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
 
 export function Part(props: MessagePartProps) {
   const component = createMemo(() => PART_MAPPING[props.part.type])
-  const part = createMemo(() => sanitizePart(unwrap(props.part), props.sanitize))
+  const part = createMemo(() => sanitizePart(props.part, props.sanitize))
   return (
     <Show when={component()}>
       <Dynamic component={component()} part={part()} message={props.message} hideDetails={props.hideDetails} />
@@ -176,7 +174,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
 
 PART_MAPPING["text"] = function TextPartDisplay(props) {
   const part = props.part as TextPart
-  const sanitized = createMemo(() => (props.sanitize ? (sanitizePart(unwrap(part), props.sanitize) as TextPart) : part))
+  const sanitized = createMemo(() => (props.sanitize ? (sanitizePart(part, props.sanitize) as TextPart) : part))
   return (
     <Show when={part.text.trim()}>
       <div data-component="text-part">

+ 9 - 9
packages/ui/src/components/message-progress.tsx

@@ -86,30 +86,30 @@ export function MessageProgress(props: MessageProgressProps) {
     if (last.type === "tool") {
       switch (last.tool) {
         case "task":
-          return "Delegating work..."
+          return "Delegating work"
         case "todowrite":
         case "todoread":
-          return "Planning next steps..."
+          return "Planning next steps"
         case "read":
-          return "Gathering context..."
+          return "Gathering context"
         case "list":
         case "grep":
         case "glob":
-          return "Searching the codebase..."
+          return "Searching the codebase"
         case "webfetch":
-          return "Searching the web..."
+          return "Searching the web"
         case "edit":
         case "write":
-          return "Making edits..."
+          return "Making edits"
         case "bash":
-          return "Running commands..."
+          return "Running commands"
         default:
           break
       }
     } else if (last.type === "reasoning") {
-      return "Thinking..."
+      return "Thinking"
     } else if (last.type === "text") {
-      return "Gathering thoughts..."
+      return "Gathering thoughts"
     }
     return undefined
   })

+ 16 - 11
packages/ui/src/components/session-turn.css

@@ -274,22 +274,27 @@
     min-width: 0;
   }
 
+  [data-slot="session-turn-collapsible"] {
+    gap: 32px;
+  }
+
   [data-slot="session-turn-collapsible-trigger-content"] {
-    color: var(--text-weak);
-    cursor: pointer;
-    background: none;
-    border: none;
-    padding: 0;
+    width: fit-content;
     display: flex;
     align-items: center;
+    gap: 4px;
+    color: var(--text-weak);
 
-    &:hover {
-      color: var(--text-strong);
+    [data-component="spinner"] {
+      width: 12px;
+      height: 12px;
+      margin-right: 4px;
+    }
+
+    [data-component="icon"] {
+      width: 14px;
+      height: 14px;
     }
-    display: flex;
-    align-items: center;
-    gap: 4px;
-    align-self: stretch;
   }
 
   [data-slot="session-turn-details-text"] {

+ 203 - 147
packages/ui/src/components/session-turn.tsx

@@ -1,9 +1,9 @@
-import { AssistantMessage } from "@opencode-ai/sdk/v2"
+import { AssistantMessage, ToolPart } from "@opencode-ai/sdk/v2/client"
 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, ParentProps, Show, Switch } from "solid-js"
+import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
 import { DiffChanges } from "./diff-changes"
 import { Typewriter } from "./typewriter"
 import { Message } from "./message-part"
@@ -13,16 +13,12 @@ import { StickyAccordionHeader } from "./sticky-accordion-header"
 import { FileIcon } from "./file-icon"
 import { Icon } from "./icon"
 import { Card } from "./card"
-import { MessageProgress } from "./message-progress"
 import { Collapsible } from "./collapsible"
 import { Dynamic } from "solid-js/web"
-
-// Track animation state per message ID - persists across re-renders
-// "empty" = first saw with no value (should animate when value arrives)
-// "animating" = currently animating (keep returning true)
-// "done" = already animated or first saw with value (never animate)
-const titleAnimationState = new Map<string, "empty" | "animating" | "done">()
-const summaryAnimationState = new Map<string, "empty" | "animating" | "done">()
+import { Button } from "./button"
+import { Spinner } from "./spinner"
+import { createStore } from "solid-js/store"
+import { DateTime, DurationUnit, Interval } from "luxon"
 
 export function SessionTurn(
   props: ParentProps<{
@@ -44,11 +40,7 @@ export function SessionTurn(
       .filter((m) => m.role === "user")
       .sort((a, b) => a.id.localeCompare(b.id)),
   )
-  const lastUserMessage = createMemo(() => {
-    return userMessages()?.at(-1)
-  })
   const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
-
   const status = createMemo(
     () =>
       data.store.session_status[props.sessionID] ?? {
@@ -61,114 +53,231 @@ export function SessionTurn(
     <div data-component="session-turn" class={props.classes?.root}>
       <div data-slot="session-turn-content" class={props.classes?.content}>
         <Show when={message()}>
-          {(msg) => {
-            const [detailsExpanded, setDetailsExpanded] = createSignal(false)
-
-            // Animation logic: only animate if we witness the value transition from empty to non-empty
-            // Track in module-level Maps keyed by message ID so it persists across re-renders
-
-            // Initialize animation state for current message (reactive - runs when msg().id changes)
-            createEffect(() => {
-              const id = msg().id
-              if (!titleAnimationState.has(id)) {
-                titleAnimationState.set(id, msg().summary?.title ? "done" : "empty")
-              }
-              if (!summaryAnimationState.has(id)) {
-                const assistantMsgs = messages()?.filter(
-                  (m) => m.role === "assistant" && m.parentID == id,
-                ) as AssistantMessage[]
-                const parts = assistantMsgs?.flatMap((m) => data.store.part[m.id])
-                const lastText = parts?.filter((p) => p?.type === "text")?.at(-1)
-                const summaryValue = msg().summary?.body ?? lastText?.text
-                summaryAnimationState.set(id, summaryValue ? "done" : "empty")
-              }
-
-              // When message changes or component unmounts, mark any "animating" states as "done"
-              onCleanup(() => {
-                if (titleAnimationState.get(id) === "animating") {
-                  titleAnimationState.set(id, "done")
-                }
-                if (summaryAnimationState.get(id) === "animating") {
-                  summaryAnimationState.set(id, "done")
-                }
-              })
-            })
-
+          {(message) => {
             const assistantMessages = createMemo(() => {
-              return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[]
+              return messages()?.filter(
+                (m) => m.role === "assistant" && m.parentID == message().id,
+              ) as AssistantMessage[]
             })
+            const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1))
             const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
             const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
-            const parts = createMemo(() => data.store.part[msg().id])
+            const parts = createMemo(() => data.store.part[message().id])
             const lastTextPart = createMemo(() =>
               assistantMessageParts()
                 .filter((p) => p?.type === "text")
                 ?.at(-1),
             )
-            const hasToolPart = createMemo(() => assistantMessageParts().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)
-            const summary = createMemo(() => msg().summary?.body ?? lastTextPart()?.text)
-            const lastTextPartShown = createMemo(() => !msg().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0)
+            const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text)
+            const lastTextPartShown = createMemo(
+              () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0,
+            )
 
-            // Should animate: state is "empty" AND value now exists, or state is "animating"
-            // Transition: empty -> animating -> done (done happens on cleanup)
-            const animateTitle = createMemo(() => {
-              const id = msg().id
-              const state = titleAnimationState.get(id)
-              const title = msg().summary?.title
-              if (state === "animating") {
-                return true
-              }
-              if (state === "empty" && title) {
-                titleAnimationState.set(id, "animating")
-                return true
+            const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
+            const currentTask = createMemo(
+              () =>
+                assistantParts().findLast(
+                  (p) =>
+                    p &&
+                    p.type === "tool" &&
+                    p.tool === "task" &&
+                    p.state &&
+                    "metadata" in p.state &&
+                    p.state.metadata &&
+                    p.state.metadata.sessionId &&
+                    p.state.status === "running",
+                ) as ToolPart,
+            )
+            const resolvedParts = createMemo(() => {
+              let resolved = assistantParts()
+              const task = currentTask()
+              if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
+                const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
+                  (m) => m.role === "assistant",
+                )
+                resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts()
               }
-              return false
+              return resolved
             })
-            const animateSummary = createMemo(() => {
-              const id = msg().id
-              const state = summaryAnimationState.get(id)
-              const value = summary()
-              if (state === "animating") {
-                return true
-              }
-              if (state === "empty" && value) {
-                summaryAnimationState.set(id, "animating")
-                return true
+            const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
+            const rawStatus = createMemo(() => {
+              const last = lastPart()
+              if (!last) return undefined
+
+              if (last.type === "tool") {
+                switch (last.tool) {
+                  case "task":
+                    return "Delegating work"
+                  case "todowrite":
+                  case "todoread":
+                    return "Planning next steps"
+                  case "read":
+                    return "Gathering context"
+                  case "list":
+                  case "grep":
+                  case "glob":
+                    return "Searching the codebase"
+                  case "webfetch":
+                    return "Searching the web"
+                  case "edit":
+                  case "write":
+                    return "Making edits"
+                  case "bash":
+                    return "Running commands"
+                  default:
+                    break
+                }
+              } else if (last.type === "reasoning") {
+                return "Thinking"
+              } else if (last.type === "text") {
+                return "Gathering thoughts"
               }
-              return false
+              return undefined
+            })
+
+            function duration() {
+              const completed = lastAssistantMessage()?.time.completed
+              const from = DateTime.fromMillis(message()!.time.created)
+              const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
+              const interval = Interval.fromDateTimes(from, to)
+              const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
+
+              return interval.toDuration(unit).normalize().toHuman({
+                notation: "compact",
+                unitDisplay: "narrow",
+                compactDisplay: "short",
+                showZeros: false,
+              })
+            }
+
+            const [store, setStore] = createStore({
+              status: rawStatus(),
+              detailsExpanded: true,
+              duration: duration(),
             })
 
             createEffect(() => {
-              const done = !messageWorking()
-              setTimeout(() => setCompleted(done), 1200)
+              const timer = setInterval(() => {
+                setStore("duration", duration())
+              }, 1000)
+              onCleanup(() => clearInterval(timer))
+            })
+
+            let lastStatusChange = Date.now()
+            let statusTimeout: number | undefined
+            createEffect(() => {
+              const newStatus = rawStatus()
+              if (newStatus === store.status || !newStatus) return
+
+              const timeSinceLastChange = Date.now() - lastStatusChange
+
+              if (timeSinceLastChange >= 2500) {
+                setStore("status", newStatus)
+                lastStatusChange = Date.now()
+                if (statusTimeout) {
+                  clearTimeout(statusTimeout)
+                  statusTimeout = undefined
+                }
+              } else {
+                if (statusTimeout) clearTimeout(statusTimeout)
+                statusTimeout = setTimeout(() => {
+                  setStore("status", rawStatus())
+                  lastStatusChange = Date.now()
+                  statusTimeout = undefined
+                }, 1000 - timeSinceLastChange) as unknown as number
+              }
             })
 
             return (
-              <div data-message={msg().id} data-slot="session-turn-message-container" class={props.classes?.container}>
+              <div
+                data-message={message().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={!animateTitle()}
-                      fallback={<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />}
-                    >
-                      <h1>{msg().summary?.title}</h1>
-                    </Show>
+                    <Switch>
+                      <Match when={working()}>
+                        <Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
+                      </Match>
+                      <Match when={true}>
+                        <h1>{message().summary?.title}</h1>
+                      </Match>
+                    </Switch>
                   </div>
                 </div>
                 <div data-slot="session-turn-message-content">
-                  <Message message={msg()} parts={parts()} sanitize={sanitizer()} />
+                  <Message message={message()} parts={parts()} sanitize={sanitizer()} />
+                </div>
+                {/* Response */}
+                <div data-slot="session-turn-response-section">
+                  <Collapsible
+                    variant="ghost"
+                    open={store.detailsExpanded}
+                    onOpenChange={(open) => setStore("detailsExpanded", open)}
+                    data-slot="session-turn-collapsible"
+                  >
+                    <Collapsible.Trigger
+                      as={Button}
+                      data-slot="session-turn-collapsible-trigger-content"
+                      variant="ghost"
+                      size="small"
+                    >
+                      <Show when={working()}>
+                        <Spinner />
+                      </Show>
+                      <Switch>
+                        <Match when={working()}>{store.status ?? "Considering next steps..."}</Match>
+                        <Match when={store.detailsExpanded}>Hide steps</Match>
+                        <Match when={!store.detailsExpanded}>Show steps</Match>
+                      </Switch>
+                      <span>·</span>
+                      <span>{store.duration}</span>
+                      <Icon name="chevron-grabber-vertical" size="small" />
+                    </Collapsible.Trigger>
+                    <Collapsible.Content>
+                      <div data-slot="session-turn-collapsible-content-inner">
+                        <For each={assistantMessages()}>
+                          {(assistantMessage) => {
+                            const parts = createMemo(() => data.store.part[assistantMessage.id] ?? [])
+                            const last = createMemo(() =>
+                              parts()
+                                .filter((p) => p?.type === "text")
+                                .at(-1),
+                            )
+                            return (
+                              <Switch>
+                                <Match when={lastTextPartShown() && lastTextPart()?.id === last()?.id}>
+                                  <Message
+                                    message={assistantMessage}
+                                    parts={parts().filter((p) => p?.id !== last()?.id)}
+                                    sanitize={sanitizer()}
+                                  />
+                                </Match>
+                                <Match when={true}>
+                                  <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} />
+                                </Match>
+                              </Switch>
+                            )
+                          }}
+                        </For>
+                        <Show when={error()}>
+                          <Card variant="error" class="error-card">
+                            {error()?.data?.message as string}
+                          </Card>
+                        </Show>
+                      </div>
+                    </Collapsible.Content>
+                  </Collapsible>
                 </div>
                 {/* Summary */}
-                <Show when={completed()}>
+                <Show when={!working()}>
                   <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={message().summary?.diffs?.length}>Summary</Match>
                           <Match when={true}>Response</Match>
                         </Switch>
                       </h2>
@@ -176,15 +285,14 @@ export function SessionTurn(
                         {(summary) => (
                           <Markdown
                             data-slot="session-turn-markdown"
-                            data-diffs={!!msg().summary?.diffs?.length}
-                            data-fade={!msg().summary?.diffs?.length && animateSummary()}
+                            data-diffs={!!message().summary?.diffs?.length}
                             text={summary()}
                           />
                         )}
                       </Show>
                     </div>
                     <Accordion data-slot="session-turn-accordion" multiple>
-                      <For each={msg().summary?.diffs ?? []}>
+                      <For each={message().summary?.diffs ?? []}>
                         {(diff) => (
                           <Accordion.Item value={diff.file}>
                             <StickyAccordionHeader>
@@ -230,63 +338,11 @@ export function SessionTurn(
                     </Accordion>
                   </div>
                 </Show>
-                <Show when={error() && !detailsExpanded()}>
+                <Show when={error() && !store.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.store.part[assistantMessage.id])
-                                const last = createMemo(() =>
-                                  parts()
-                                    .filter((p) => p?.type === "text")
-                                    .at(-1),
-                                )
-                                if (lastTextPartShown() && lastTextPart()?.id === last()?.id) {
-                                  return (
-                                    <Message
-                                      message={assistantMessage}
-                                      parts={parts().filter((p) => p?.id !== last()?.id)}
-                                      sanitize={sanitizer()}
-                                    />
-                                  )
-                                }
-                                return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} />
-                              }}
-                            </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>
             )
           }}