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

+ 72 - 52
packages/desktop/src/components/message-progress.tsx

@@ -1,7 +1,8 @@
-import { For, JSXElement, Match, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
-import { Part } from "@opencode-ai/ui"
+import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
+import { Markdown, Part } from "@opencode-ai/ui"
 import { useSync } from "@/context/sync"
 import type { AssistantMessage as AssistantMessageType, Part as PartType, ToolPart } from "@opencode-ai/sdk"
+import { Spinner } from "./spinner"
 
 export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
   const sync = useSync()
@@ -22,37 +23,42 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
       ) as ToolPart,
   )
 
-  const eligibleItems = createMemo(() => {
-    let allParts = parts()
+  const resolvedParts = createMemo(() => {
+    let resolved = parts()
     const task = currentTask()
     if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
       const messages = sync.data.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant")
-      allParts = messages?.flatMap((m) => sync.data.part[m.id]) ?? parts()
+      resolved = messages?.flatMap((m) => sync.data.part[m.id]) ?? parts()
     }
-    return allParts.filter(
-      (p) =>
-        p?.type === "text" ||
-        (p?.type === "reasoning" && p.time?.end) ||
-        (p?.type === "tool" && p.state.status === "completed"),
-    )
+    return resolved
+  })
+  const currentText = createMemo(
+    () =>
+      resolvedParts().findLast((p) => p?.type === "text")?.text ||
+      resolvedParts().findLast((p) => p?.type === "reasoning")?.text,
+  )
+  const eligibleItems = createMemo(() => {
+    return resolvedParts().filter((p) => p?.type === "tool" && p.state.status === "completed")
   })
   const finishedItems = createMemo<(JSXElement | PartType)[]>(() => [
-    "",
-    "",
-    <div class="text-text-diff-add-base">Loading...</div>,
+    <div class="h-8 w-full" />,
+    <div class="h-8 w-full" />,
+    <div class="flex items-center gap-x-5 pl-3 text-text-base">
+      <Spinner /> <span class="text-12-medium">Thinking...</span>
+    </div>,
     ...eligibleItems(),
-    ...(done() ? ["", "", ""] : []),
+    ...(done() ? [<div class="h-8 w-full" />, <div class="h-8 w-full" />, <div class="h-8 w-full" />] : []),
   ])
 
-  const MINIMUM_DELAY = 400
-  const [visibleCount, setVisibleCount] = createSignal(1)
+  const delay = createMemo(() => (done() ? 220 : 400))
+  const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
 
   createEffect(() => {
     const total = finishedItems().length
     if (total > visibleCount()) {
       const timer = setTimeout(() => {
         setVisibleCount((prev) => prev + 1)
-      }, MINIMUM_DELAY)
+      }, delay())
       onCleanup(() => clearTimeout(timer))
     } else if (total < visibleCount()) {
       setVisibleCount(total)
@@ -66,43 +72,57 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
   })
 
   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="flex flex-col gap-3">
       <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()})` }}
+        class="h-30 overflow-hidden pointer-events-none pb-1 
+               mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
+               mask-b-from-95% mask-b-from-background-base mask-b-to-transparent"
       >
-        <For each={finishedItems()}>
-          {(part) => {
-            if (part && typeof part === "object" && "type" in part) {
-              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>
-              )
-            }
-            return <div class="h-8 flex items-center w-full">{part}</div>
-          }}
-        </For>
+        <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 (part && typeof part === "object" && "type" in part) {
+                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>
+                )
+              }
+              return <div class="h-8 flex items-center w-full">{part}</div>
+            }}
+          </For>
+        </div>
       </div>
+      <Show when={currentText()}>
+        {(text) => (
+          <div
+            class="max-h-36 flex flex-col justify-end overflow-hidden py-3
+                   mask-alpha mask-t-from-80% mask-t-from-background-base mask-t-to-transparent"
+          >
+            <Markdown text={text()} class="w-full shrink-0 overflow-visible" />
+          </div>
+        )}
+      </Show>
     </div>
   )
 }

+ 1 - 1
packages/desktop/src/components/prompt-input.tsx

@@ -334,7 +334,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         onSubmit={handleSubmit}
         classList={{
           "bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
-          "rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
+          "rounded-2xl overflow-clip focus-within:border-transparent focus-within:shadow-xs-border-select": true,
           [props.class ?? ""]: !!props.class,
         }}
       >

+ 98 - 10
packages/desktop/src/pages/index.tsx

@@ -548,7 +548,79 @@ export default function Page() {
                               <For each={local.session.userMessages()}>
                                 {(message) => {
                                   const diffs = createMemo(() => message.summary?.diffs ?? [])
-                                  const working = createMemo(() => !message.summary?.title)
+                                  const working = createMemo(() => !message.summary?.body)
+                                  const assistantMessages = createMemo(() => {
+                                    return sync.data.message[activeSession().id]?.filter(
+                                      (m) => m.role === "assistant" && m.parentID == message.id,
+                                    ) as AssistantMessageType[]
+                                  })
+                                  const parts = createMemo(() =>
+                                    assistantMessages().flatMap((m) => sync.data.part[m.id]),
+                                  )
+                                  const lastPart = createMemo(() => parts().slice(-1)?.at(0))
+                                  const rawStatus = createMemo(() => {
+                                    const defaultStatus = "Working..."
+                                    const last = lastPart()
+                                    if (!last) return defaultStatus
+
+                                    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 defaultStatus
+                                  })
+
+                                  const [status, setStatus] = createSignal(rawStatus())
+                                  let lastStatusChange = Date.now()
+                                  let statusTimeout: number | undefined
+
+                                  createEffect(() => {
+                                    const newStatus = rawStatus()
+                                    if (newStatus === status()) return
+
+                                    const timeSinceLastChange = Date.now() - lastStatusChange
+
+                                    if (timeSinceLastChange >= 1000) {
+                                      setStatus(newStatus)
+                                      lastStatusChange = Date.now()
+                                      if (statusTimeout) {
+                                        clearTimeout(statusTimeout)
+                                        statusTimeout = undefined
+                                      }
+                                    } else {
+                                      if (statusTimeout) clearTimeout(statusTimeout)
+                                      statusTimeout = setTimeout(() => {
+                                        setStatus(rawStatus())
+                                        lastStatusChange = Date.now()
+                                        statusTimeout = undefined
+                                      }, 1000 - timeSinceLastChange) as unknown as number
+                                    }
+                                  })
+
                                   return (
                                     <li class="group/li flex items-center self-stretch">
                                       <button
@@ -570,7 +642,10 @@ export default function Page() {
                                             "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
                                           }}
                                         >
-                                          {message.summary?.title ?? local.session.getMessageText(message)}
+                                          <Switch>
+                                            <Match when={working()}>{status()}</Match>
+                                            <Match when={true}>{message.summary?.title}</Match>
+                                          </Switch>
                                         </div>
                                       </button>
                                     </li>
@@ -604,10 +679,12 @@ export default function Page() {
 
                                 // allowing time for the animations to finish
                                 createEffect(() => {
+                                  title()
                                   setTimeout(() => setTitled(!!title()), 10_000)
                                 })
                                 createEffect(() => {
-                                  setTimeout(() => setCompleted(!!summary()), 3_000)
+                                  summary()
+                                  setTimeout(() => setCompleted(!!summary()), 1200)
                                 })
 
                                 return (
@@ -618,9 +695,18 @@ export default function Page() {
                                     >
                                       {/* Title */}
                                       <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
-                                        <div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
-                                          <Show when={titled()} fallback={<Typewriter as="h1" text={title()} />}>
-                                            <h1>{title()}</h1>
+                                        <div class="w-full text-14-medium text-text-strong">
+                                          <Show
+                                            when={titled()}
+                                            fallback={
+                                              <Typewriter
+                                                as="h1"
+                                                text={title()}
+                                                class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
+                                              />
+                                            }
+                                          >
+                                            <h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">{title()}</h1>
                                           </Show>
                                         </div>
                                       </div>
@@ -628,7 +714,7 @@ export default function Page() {
                                         <Message message={message} parts={parts()} />
                                       </div>
                                       {/* Summary */}
-                                      <Show when={!working()}>
+                                      <Show when={completed()}>
                                         <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">
@@ -637,7 +723,9 @@ export default function Page() {
                                                 <Match when={true}>Response</Match>
                                               </Switch>
                                             </h2>
-                                            <Show when={summary()}>{(summary) => <Markdown text={summary()} />}</Show>
+                                            <Show when={summary()}>
+                                              {(summary) => <Markdown class="[&>*]:fade-up-text" text={summary()} />}
+                                            </Show>
                                           </div>
                                           <Accordion class="w-full" multiple>
                                             <For each={diffs()}>
@@ -699,8 +787,8 @@ export default function Page() {
                                                 <div class="flex items-center gap-1 self-stretch">
                                                   <div class="text-12-medium">
                                                     <Switch>
-                                                      <Match when={expanded()}>Hide steps</Match>
-                                                      <Match when={!expanded()}>Show steps</Match>
+                                                      <Match when={expanded()}>Hide details</Match>
+                                                      <Match when={!expanded()}>Show details</Match>
                                                     </Switch>
                                                   </div>
                                                   <Collapsible.Arrow />

+ 3 - 3
packages/ui/src/components/markdown.css

@@ -6,12 +6,12 @@
   color: var(--text-base);
   text-wrap: pretty;
 
-  /* text-14-regular */
+  /* text-12-regular */
   font-family: var(--font-family-sans);
-  font-size: var(--font-size-base);
+  font-size: var(--font-size-small);
   font-style: normal;
   font-weight: var(--font-weight-regular);
-  line-height: var(--line-height-large); /* 142.857% */
+  line-height: var(--line-height-large); /* 166.667% */
   letter-spacing: var(--letter-spacing-normal);
 
   &::-webkit-scrollbar {

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

@@ -11,3 +11,110 @@
     opacity: 1;
   }
 }
+
+@keyframes fadeUp {
+  from {
+    opacity: 0;
+    transform: translateY(5px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.fade-up-text {
+  animation: fadeUp 0.4s ease-out forwards;
+  opacity: 0;
+
+  &:nth-child(1) {
+    animation-delay: 0.1s;
+  }
+  &:nth-child(2) {
+    animation-delay: 0.2s;
+  }
+  &:nth-child(3) {
+    animation-delay: 0.3s;
+  }
+  &:nth-child(4) {
+    animation-delay: 0.4s;
+  }
+  &:nth-child(5) {
+    animation-delay: 0.5s;
+  }
+  &:nth-child(6) {
+    animation-delay: 0.6s;
+  }
+  &:nth-child(7) {
+    animation-delay: 0.7s;
+  }
+  &:nth-child(8) {
+    animation-delay: 0.8s;
+  }
+  &:nth-child(9) {
+    animation-delay: 0.9s;
+  }
+  &:nth-child(10) {
+    animation-delay: 1s;
+  }
+  &:nth-child(11) {
+    animation-delay: 1.1s;
+  }
+  &:nth-child(12) {
+    animation-delay: 1.2s;
+  }
+  &:nth-child(13) {
+    animation-delay: 1.3s;
+  }
+  &:nth-child(14) {
+    animation-delay: 1.4s;
+  }
+  &:nth-child(15) {
+    animation-delay: 1.5s;
+  }
+  &:nth-child(16) {
+    animation-delay: 1.6s;
+  }
+  &:nth-child(17) {
+    animation-delay: 1.7s;
+  }
+  &:nth-child(18) {
+    animation-delay: 1.8s;
+  }
+  &:nth-child(19) {
+    animation-delay: 1.9s;
+  }
+  &:nth-child(20) {
+    animation-delay: 2s;
+  }
+  &:nth-child(21) {
+    animation-delay: 2.1s;
+  }
+  &:nth-child(22) {
+    animation-delay: 2.2s;
+  }
+  &:nth-child(23) {
+    animation-delay: 2.3s;
+  }
+  &:nth-child(24) {
+    animation-delay: 2.4s;
+  }
+  &:nth-child(25) {
+    animation-delay: 2.5s;
+  }
+  &:nth-child(26) {
+    animation-delay: 2.6s;
+  }
+  &:nth-child(27) {
+    animation-delay: 2.7s;
+  }
+  &:nth-child(28) {
+    animation-delay: 2.8s;
+  }
+  &:nth-child(29) {
+    animation-delay: 2.9s;
+  }
+  &:nth-child(30) {
+    animation-delay: 3s;
+  }
+}

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

@@ -63,7 +63,7 @@
 
   --shadow-xs: var(--shadow-xs);
   --shadow-md: var(--shadow-md);
-  --shadow-xs-border-selected: var(--shadow-xs-border-selected);
+  --shadow-xs-border-select: var(--shadow-xs-border-select);
 
   --animate-pulse: var(--animate-pulse);
 }

+ 96 - 0
packages/ui/src/styles/tailwind/utilities.css

@@ -15,3 +15,99 @@
   direction: rtl;
   text-align: left;
 }
+
+@utility fade-up-text {
+  animation: fadeUp 0.4s ease-out forwards;
+  opacity: 0;
+
+  &:nth-child(1) {
+    animation-delay: 0.1s;
+  }
+  &:nth-child(2) {
+    animation-delay: 0.2s;
+  }
+  &:nth-child(3) {
+    animation-delay: 0.3s;
+  }
+  &:nth-child(4) {
+    animation-delay: 0.4s;
+  }
+  &:nth-child(5) {
+    animation-delay: 0.5s;
+  }
+  &:nth-child(6) {
+    animation-delay: 0.6s;
+  }
+  &:nth-child(7) {
+    animation-delay: 0.7s;
+  }
+  &:nth-child(8) {
+    animation-delay: 0.8s;
+  }
+  &:nth-child(9) {
+    animation-delay: 0.9s;
+  }
+  &:nth-child(10) {
+    animation-delay: 1s;
+  }
+  &:nth-child(11) {
+    animation-delay: 1.1s;
+  }
+  &:nth-child(12) {
+    animation-delay: 1.2s;
+  }
+  &:nth-child(13) {
+    animation-delay: 1.3s;
+  }
+  &:nth-child(14) {
+    animation-delay: 1.4s;
+  }
+  &:nth-child(15) {
+    animation-delay: 1.5s;
+  }
+  &:nth-child(16) {
+    animation-delay: 1.6s;
+  }
+  &:nth-child(17) {
+    animation-delay: 1.7s;
+  }
+  &:nth-child(18) {
+    animation-delay: 1.8s;
+  }
+  &:nth-child(19) {
+    animation-delay: 1.9s;
+  }
+  &:nth-child(20) {
+    animation-delay: 2s;
+  }
+  &:nth-child(21) {
+    animation-delay: 2.1s;
+  }
+  &:nth-child(22) {
+    animation-delay: 2.2s;
+  }
+  &:nth-child(23) {
+    animation-delay: 2.3s;
+  }
+  &:nth-child(24) {
+    animation-delay: 2.4s;
+  }
+  &:nth-child(25) {
+    animation-delay: 2.5s;
+  }
+  &:nth-child(26) {
+    animation-delay: 2.6s;
+  }
+  &:nth-child(27) {
+    animation-delay: 2.7s;
+  }
+  &:nth-child(28) {
+    animation-delay: 2.8s;
+  }
+  &:nth-child(29) {
+    animation-delay: 2.9s;
+  }
+  &:nth-child(30) {
+    animation-delay: 3s;
+  }
+}