Adam 3 месяцев назад
Родитель
Сommit
ffc889b99e

+ 82 - 0
packages/desktop/src/components/message-progress.tsx

@@ -0,0 +1,82 @@
+import { For, Match, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
+import { Part } from "@opencode-ai/ui"
+import { useSync } from "@/context/sync"
+import type { AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
+
+export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[] }) {
+  const sync = useSync()
+  const items = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id]))
+
+  const finishedItems = createMemo(() => [
+    "",
+    "",
+    "Loading...",
+    ...items().filter(
+      (p) =>
+        p?.type === "text" ||
+        (p?.type === "reasoning" && p.time?.end) ||
+        (p?.type === "tool" && p.state.status === "completed"),
+    ),
+    "",
+  ])
+
+  const MINIMUM_DELAY = 400
+  const [visibleCount, setVisibleCount] = createSignal(1)
+
+  createEffect(() => {
+    const total = finishedItems().length
+    if (total > visibleCount()) {
+      const timer = setTimeout(() => {
+        setVisibleCount((prev) => prev + 1)
+      }, MINIMUM_DELAY)
+      onCleanup(() => clearTimeout(timer))
+    } else if (total < visibleCount()) {
+      setVisibleCount(total)
+    }
+  })
+
+  const translateY = createMemo(() => {
+    const total = visibleCount()
+    if (total < 2) return "0px"
+    return `-${(total - 2) * 40 - 8}px`
+  })
+
+  return (
+    <div
+      class="h-30 overflow-hidden pointer-events-none 
+             mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
+             mask-b-from-90% mask-b-from-background-base mask-b-to-transparent"
+    >
+      <div
+        class="w-full flex flex-col items-start self-stretch gap-2 py-8
+               transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
+        style={{ transform: `translateY(${translateY()})` }}
+      >
+        <For each={finishedItems()}>
+          {(part) => {
+            if (typeof part === "string") return <div class="h-8 flex items-center w-full">{part}</div>
+            const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
+            return (
+              <div class="h-8 flex items-center w-full">
+                <Switch>
+                  <Match when={part.type === "text" && part}>
+                    {(p) => (
+                      <div
+                        textContent={p().text}
+                        class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
+                      />
+                    )}
+                  </Match>
+                  <Match when={part.type === "reasoning" && part}>
+                    {(p) => <Part message={message()!} part={p()} />}
+                  </Match>
+                  <Match when={part.type === "tool" && part}>{(p) => <Part message={message()!} part={p()} />}</Match>
+                </Switch>
+              </div>
+            )
+          }}
+        </For>
+      </div>
+    </div>
+  )
+}

+ 39 - 0
packages/desktop/src/components/spinner.tsx

@@ -0,0 +1,39 @@
+import { ComponentProps, For } from "solid-js"
+
+export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
+  const squares = Array.from({ length: 16 }, (_, i) => ({
+    id: i,
+    x: (i % 4) * 4,
+    y: Math.floor(i / 4) * 4,
+    delay: Math.random() * 3,
+    duration: 2 + Math.random() * 2,
+  }))
+
+  return (
+    <svg
+      viewBox="0 0 15 15"
+      classList={{
+        "size-4": true,
+        ...(props.classList ?? {}),
+        [props.class ?? ""]: !!props.class,
+      }}
+      fill="currentColor"
+    >
+      <For each={squares}>
+        {(square) => (
+          <rect
+            x={square.x}
+            y={square.y}
+            width="3"
+            height="3"
+            rx="1"
+            style={{
+              animation: `pulse-opacity ${square.duration}s ease-in-out infinite`,
+              "animation-delay": `${square.delay}s`,
+            }}
+          />
+        )}
+      </For>
+    </svg>
+  )
+}

+ 30 - 100
packages/desktop/src/pages/index.tsx

@@ -17,6 +17,7 @@ import {
 } from "@opencode-ai/ui"
 import { FileIcon } from "@/ui"
 import FileTree from "@/components/file-tree"
+import { MessageProgress } from "@/components/message-progress"
 import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
 import { useLocal, type LocalFile } from "@/context/local"
 import { createStore } from "solid-js/store"
@@ -39,6 +40,7 @@ import { useSync } from "@/context/sync"
 import { useSDK } from "@/context/sdk"
 import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
 import { Markdown } from "@opencode-ai/ui"
+import { Spinner } from "@/components/spinner"
 
 export default function Page() {
   const local = useLocal()
@@ -546,21 +548,31 @@ export default function Page() {
                               <For each={local.session.userMessages()}>
                                 {(message) => {
                                   const diffs = createMemo(() => message.summary?.diffs ?? [])
+                                  const working = createMemo(() => !message.summary?.title)
                                   return (
-                                    <li
-                                      class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
-                                      onClick={() => local.session.setActiveMessage(message.id)}
-                                    >
-                                      <DiffChanges diff={diffs()} variant="bars" />
-                                      <div
-                                        data-active={local.session.activeMessage()?.id === message.id}
-                                        classList={{
-                                          "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
-                                          "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
-                                        }}
+                                    <li class="group/li flex items-center self-stretch">
+                                      <button
+                                        class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
+                                        onClick={() => local.session.setActiveMessage(message.id)}
                                       >
-                                        {message.summary?.title ?? local.session.getMessageText(message)}
-                                      </div>
+                                        <Switch>
+                                          <Match when={working()}>
+                                            <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
+                                          </Match>
+                                          <Match when={true}>
+                                            <DiffChanges diff={diffs()} variant="bars" />
+                                          </Match>
+                                        </Switch>
+                                        <div
+                                          data-active={local.session.activeMessage()?.id === message.id}
+                                          classList={{
+                                            "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
+                                            "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
+                                          }}
+                                        >
+                                          {message.summary?.title ?? local.session.getMessageText(message)}
+                                        </div>
+                                      </button>
                                     </li>
                                   )
                                 }}
@@ -600,19 +612,15 @@ export default function Page() {
                                           </Show>
                                         </div>
                                       </div>
-                                      <Show when={title}>
-                                        <div class="-mt-8">
-                                          <Message message={message} parts={parts()} />
-                                        </div>
-                                      </Show>
+                                      <div class="-mt-8">
+                                        <Message message={message} parts={parts()} />
+                                      </div>
                                       {/* Summary */}
                                       <Show when={!working()}>
                                         <div class="w-full flex flex-col gap-6 items-start self-stretch">
                                           <div class="flex flex-col items-start gap-1 self-stretch">
                                             <h2 class="text-12-medium text-text-weak">Summary</h2>
-                                            <Show when={summary()}>
-                                              <Markdown text={summary()!} />
-                                            </Show>
+                                            <Show when={summary()}>{(summary) => <Markdown text={summary()} />}</Show>
                                           </div>
                                           <Accordion class="w-full" multiple>
                                             <For each={message.summary?.diffs || []}>
@@ -666,85 +674,7 @@ export default function Page() {
                                       <div class="w-full">
                                         <Switch>
                                           <Match when={working()}>
-                                            {(_) => {
-                                              const items = createMemo(() =>
-                                                assistantMessages().flatMap((m) => sync.data.part[m.id]),
-                                              )
-                                              const finishedItems = createMemo(() =>
-                                                items().filter(
-                                                  (p) =>
-                                                    (p?.type === "text" && p.time?.end) ||
-                                                    (p?.type === "reasoning" && p.time?.end) ||
-                                                    (p?.type === "tool" && p.state.status === "completed"),
-                                                ),
-                                              )
-
-                                              const MINIMUM_DELAY = 800
-                                              const [visibleCount, setVisibleCount] = createSignal(1)
-
-                                              createEffect(() => {
-                                                const total = finishedItems().length
-                                                if (total > visibleCount()) {
-                                                  const timer = setTimeout(() => {
-                                                    setVisibleCount((prev) => prev + 1)
-                                                  }, MINIMUM_DELAY)
-                                                  onCleanup(() => clearTimeout(timer))
-                                                } else if (total < visibleCount()) {
-                                                  setVisibleCount(total)
-                                                }
-                                              })
-
-                                              const translateY = createMemo(() => {
-                                                const total = visibleCount()
-                                                if (total < 2) return "0px"
-                                                return `-${(total - 2) * 48 - 8}px`
-                                              })
-
-                                              return (
-                                                <div class="flex flex-col gap-3">
-                                                  <div
-                                                    class="h-36 overflow-hidden pointer-events-none 
-                                                           mask-alpha mask-y-from-66% mask-y-from-background-base mask-y-to-transparent"
-                                                  >
-                                                    <div
-                                                      class="w-full flex flex-col items-start self-stretch gap-2 py-10
-                                                             transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
-                                                      style={{ transform: `translateY(${translateY()})` }}
-                                                    >
-                                                      <For each={finishedItems()}>
-                                                        {(part) => {
-                                                          const message = createMemo(() =>
-                                                            sync.data.message[part.sessionID].find(
-                                                              (m) => m.id === part.messageID,
-                                                            ),
-                                                          )
-                                                          return (
-                                                            <div class="h-10 flex items-center w-full">
-                                                              <Switch>
-                                                                <Match when={part.type === "text" && part}>
-                                                                  {(p) => (
-                                                                    <div
-                                                                      textContent={p().text}
-                                                                      class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
-                                                                    />
-                                                                  )}
-                                                                </Match>
-                                                                <Match when={part.type === "reasoning" && part}>
-                                                                  {(p) => <Part message={message()!} part={p()} />}
-                                                                </Match>
-                                                                <Match when={part.type === "tool" && part}>
-                                                                  {(p) => <Part message={message()!} part={p()} />}
-                                                                </Match>
-                                                              </Switch>
-                                                            </div>
-                                                          )
-                                                        }}
-                                                      </For>
-                                                    </div>
-                                                  </div>
-                                                </div>
-                                              )
-                                            }}
+                                            <MessageProgress assistantMessages={assistantMessages} />
                                           </Match>
                                           <Match when={!working()}>
                                             <Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>

+ 0 - 10
packages/ui/src/components/diff-changes.tsx

@@ -16,16 +16,6 @@ export function DiffChanges(props: { diff: FileDiff | FileDiff[]; variant?: "def
   )
   const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0))
 
-  const countLines = (text: string) => {
-    if (!text) return 0
-    return text.split("\n").length
-  }
-
-  const totalBeforeLines = createMemo(() => {
-    if (!Array.isArray(props.diff)) return countLines(props.diff.before || "")
-    return props.diff.reduce((acc, diff) => acc + countLines(diff.before || ""), 0)
-  })
-
   const blockCounts = createMemo(() => {
     const TOTAL_BLOCKS = 5
 

+ 7 - 0
packages/ui/src/components/markdown.css

@@ -4,6 +4,7 @@
   overflow: auto;
   scrollbar-width: none;
   color: var(--text-base);
+  text-wrap: pretty;
 
   /* text-14-regular */
   font-family: var(--font-family-sans);
@@ -34,4 +35,10 @@
     margin-top: 16px;
     margin-bottom: 16px;
   }
+
+  hr {
+    margin-top: 8px;
+    margin-bottom: 16px;
+    border-color: var(--border-weaker-base);
+  }
 }

+ 13 - 0
packages/ui/src/styles/animations.css

@@ -0,0 +1,13 @@
+:root {
+  --animate-pulse: pulse-opacity 2s ease-in-out infinite;
+}
+
+@keyframes pulse-opacity {
+  0%,
+  100% {
+    opacity: 0;
+  }
+  50% {
+    opacity: 1;
+  }
+}

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

@@ -28,3 +28,4 @@
 @import "../components/typewriter.css" layer(components);
 
 @import "./utilities.css" layer(utilities);
+@import "./animations.css" layer(utilities);

+ 2 - 0
packages/ui/src/styles/tailwind/index.css

@@ -64,6 +64,8 @@
   --shadow-xs: var(--shadow-xs);
   --shadow-md: var(--shadow-md);
   --shadow-xs-border-selected: var(--shadow-xs-border-selected);
+
+  --animate-pulse: var(--animate-pulse);
 }
 
 @import "./colors.css";

+ 0 - 65
packages/ui/src/styles/utilities.css

@@ -48,71 +48,6 @@
   border-width: 0;
 }
 
-.scroller {
-  /* --fade-height: 1.5rem; */
-  /**/
-  /* --mask-top: linear-gradient(to bottom, transparent, black var(--fade-height)); */
-  /* --mask-bottom: linear-gradient(to top, transparent, black var(--fade-height)); */
-  /**/
-  /* mask-image: var(--mask-top), var(--mask-bottom); */
-  /* mask-repeat: no-repeat; */
-  /* mask-size: 100% var(--fade-height); */
-
-  animation: scroll-fade linear;
-  animation-timeline: scroll(self);
-}
-
-/* Define the keyframes for the mask.
-  These percentages now map to scroll positions:
-  0% = Scrolled to the top
-  100% = Scrolled to the bottom
-*/
-@keyframes scroll-fade {
-  /* At the very top (0% scroll) */
-  0% {
-    mask-image: linear-gradient(
-      to bottom,
-      black 90%,
-      /* Opaque, but start fade to bottom */ transparent 100%
-    );
-  }
-
-  /* A small amount scrolled (e.g., 5%)
-    This is where the top fade should be fully visible.
-  */
-  5% {
-    mask-image: linear-gradient(
-      to bottom,
-      transparent 0%,
-      black 10%,
-      /* Fade-in top */ black 90%,
-      /* Fade-out bottom */ transparent 100%
-    );
-  }
-
-  /* Nearing the bottom (e.g., 95%)
-    The bottom fade should start disappearing.
-  */
-  95% {
-    mask-image: linear-gradient(
-      to bottom,
-      transparent 0%,
-      black 10%,
-      /* Fade-in top */ black 90%,
-      /* Fade-out bottom */ transparent 100%
-    );
-  }
-
-  /* At the very bottom (100% scroll) */
-  100% {
-    mask-image: linear-gradient(
-      to bottom,
-      transparent 0%,
-      black 10% /* Opaque, but start fade from top */
-    );
-  }
-}
-
 .truncate-start {
   text-overflow: ellipsis;
   overflow: hidden;