Adam 5 месяцев назад
Родитель
Сommit
335d833655

+ 10 - 10
packages/desktop/index.html

@@ -1,5 +1,5 @@
 <!doctype html>
-<html lang="en" class="h-full bg-background-weak">
+<html lang="en">
   <head>
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -7,15 +7,15 @@
     <link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.svg" />
     <title>OpenCode</title>
   </head>
-  <body class="h-full overscroll-none select-none">
-    <script>
-      ;(function () {
-        const savedTheme = localStorage.getItem("theme") || "opencode"
-        const savedDarkMode = localStorage.getItem("darkMode") !== "false"
-        document.documentElement.setAttribute("data-theme", savedTheme)
-        document.documentElement.setAttribute("data-dark", savedDarkMode.toString())
-      })()
-    </script>
+  <body class="overscroll-none select-none text-12-regular">
+    <!-- <script> -->
+    <!--   ;(function () { -->
+    <!--     const savedTheme = localStorage.getItem("theme") || "opencode" -->
+    <!--     const savedDarkMode = localStorage.getItem("darkMode") !== "false" -->
+    <!--     document.documentElement.setAttribute("data-theme", savedTheme) -->
+    <!--     document.documentElement.setAttribute("data-dark", savedDarkMode.toString()) -->
+    <!--   })() -->
+    <!-- </script> -->
     <noscript>You need to enable JavaScript to run this app.</noscript>
     <div id="root"></div>
     <script src="/src/index.tsx" type="module"></script>

+ 1 - 1
packages/desktop/src/components/markdown.tsx

@@ -16,7 +16,7 @@ export function Markdown(props: { text: string; class?: string }) {
   )
   return (
     <div
-      class={`min-w-0 max-w-full text-xs overflow-auto no-scrollbar prose ${props.class ?? ""}`}
+      class={`min-w-0 max-w-full overflow-auto no-scrollbar text-14-regular text-text-base ${props.class ?? ""}`}
       innerHTML={html()}
     />
   )

+ 48 - 0
packages/desktop/src/components/progress-circle.tsx

@@ -0,0 +1,48 @@
+import { Component, createMemo } from "solid-js"
+
+interface ProgressCircleProps {
+  percentage: number
+  size?: number
+  strokeWidth?: number
+}
+
+export const ProgressCircle: Component<ProgressCircleProps> = (props) => {
+  // --- Set default values for props ---
+  const size = () => props.size || 16
+  const strokeWidth = () => props.strokeWidth || 3
+
+  // --- Constants for SVG calculation ---
+  const viewBoxSize = 16
+  const center = viewBoxSize / 2
+  const radius = () => center - strokeWidth() / 2
+  const circumference = createMemo(() => 2 * Math.PI * radius())
+
+  // --- Reactive Calculation for the progress offset ---
+  const offset = createMemo(() => {
+    const clampedPercentage = Math.max(0, Math.min(100, props.percentage || 0))
+    const progress = clampedPercentage / 100
+    return circumference() * (1 - progress)
+  })
+
+  return (
+    <svg
+      width={size()}
+      height={size()}
+      viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
+      fill="none"
+      class="transform -rotate-90"
+    >
+      <circle cx={center} cy={center} r={radius()} class="stroke-border-weak-base" stroke-width={strokeWidth()} />
+      <circle
+        cx={center}
+        cy={center}
+        r={radius()}
+        class="stroke-border-active"
+        stroke-width={strokeWidth()}
+        stroke-dasharray={circumference().toString()}
+        stroke-dashoffset={offset()}
+        style={{ transition: "stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1)" }}
+      />
+    </svg>
+  )
+}

+ 140 - 83
packages/desktop/src/components/session-timeline.tsx

@@ -1,7 +1,7 @@
 import { useLocal, useSync } from "@/context"
-import { Icon } from "@opencode-ai/ui"
+import { Icon, Tooltip } from "@opencode-ai/ui"
 import { Collapsible } from "@/ui"
-import type { Part, ToolPart } from "@opencode-ai/sdk"
+import type { AssistantMessage, Part, ToolPart } from "@opencode-ai/sdk"
 import { DateTime } from "luxon"
 import {
   createSignal,
@@ -21,6 +21,8 @@ import { Markdown } from "./markdown"
 import { Code } from "./code"
 import { createElementSize } from "@solid-primitives/resize-observer"
 import { createScrollPosition } from "@solid-primitives/scroll"
+import { ProgressCircle } from "./progress-circle"
+import { pipe, sumBy } from "remeda"
 
 function Part(props: ParentProps & ComponentProps<"div">) {
   const [local, others] = splitProps(props, ["class", "classList", "children"])
@@ -33,7 +35,7 @@ function Part(props: ParentProps & ComponentProps<"div">) {
       }}
       {...others}
     >
-      <p class="text-xs leading-4 text-left text-text-muted/60 font-medium">{local.children}</p>
+      <p class="text-12-medium text-left">{local.children}</p>
     </div>
   )
 }
@@ -45,8 +47,8 @@ function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps
         <Part>{props.title}</Part>
       </Collapsible.Trigger>
       <Collapsible.Content>
-        <p class="flex-auto py-1 text-xs min-w-0 text-pretty">
-          <span class="text-text-muted/60 break-words">{props.children}</span>
+        <p class="flex-auto min-w-0 text-pretty">
+          <span class="text-12-medium text-text-weak break-words">{props.children}</span>
         </p>
       </Collapsible.Content>
     </Collapsible>
@@ -66,7 +68,7 @@ function ReadToolPart(props: { part: ToolPart }) {
           const path = state().input["filePath"] as string
           return (
             <Part class="cursor-pointer" onClick={() => local.file.open(path)}>
-              <span class="text-text-muted">Read</span> {getFilename(path)}
+              <span class="">Read</span> {getFilename(path)}
             </Part>
           )
         }}
@@ -75,9 +77,9 @@ function ReadToolPart(props: { part: ToolPart }) {
         {(state) => (
           <div>
             <Part>
-              <span class="text-text-muted">Read</span> {getFilename(state().input["filePath"] as string)}
+              <span class="">Read</span> {getFilename(state().input["filePath"] as string)}
             </Part>
-            <div class="text-error">{sync.sanitize(state().error)}</div>
+            <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
           </div>
         )}
       </Match>
@@ -95,10 +97,9 @@ function EditToolPart(props: { part: ToolPart }) {
       <Match when={props.part.state.status === "completed" && props.part.state}>
         {(state) => (
           <CollapsiblePart
-            defaultOpen
             title={
               <>
-                <span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)}
+                <span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
               </>
             }
           >
@@ -111,11 +112,11 @@ function EditToolPart(props: { part: ToolPart }) {
           <CollapsiblePart
             title={
               <>
-                <span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)}
+                <span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
               </>
             }
           >
-            <div class="text-error">{sync.sanitize(state().error)}</div>
+            <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
           </CollapsiblePart>
         )}
       </Match>
@@ -135,7 +136,7 @@ function WriteToolPart(props: { part: ToolPart }) {
           <CollapsiblePart
             title={
               <>
-                <span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)}
+                <span class="">Write</span> {getFilename(state().input["filePath"] as string)}
               </>
             }
           >
@@ -147,9 +148,9 @@ function WriteToolPart(props: { part: ToolPart }) {
         {(state) => (
           <div>
             <Part>
-              <span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)}
+              <span class="">Write</span> {getFilename(state().input["filePath"] as string)}
             </Part>
-            <div class="text-error">{sync.sanitize(state().error)}</div>
+            <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
           </div>
         )}
       </Match>
@@ -170,7 +171,7 @@ function BashToolPart(props: { part: ToolPart }) {
             defaultOpen
             title={
               <>
-                <span class="text-text-muted">Run command:</span> {state().input["command"]}
+                <span class="">Run command:</span> {state().input["command"]}
               </>
             }
           >
@@ -183,11 +184,11 @@ function BashToolPart(props: { part: ToolPart }) {
           <CollapsiblePart
             title={
               <>
-                <span class="text-text-muted">Shell</span> {state().input["command"]}
+                <span class="">Shell</span> {state().input["command"]}
               </>
             }
           >
-            <div class="text-error">{sync.sanitize(state().error)}</div>
+            <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
           </CollapsiblePart>
         )}
       </Match>
@@ -210,7 +211,7 @@ function ToolPart(props: { part: ToolPart }) {
   // patch
   // task
   return (
-    <div class="min-w-0 flex-auto text-xs">
+    <div class="min-w-0 flex-auto text-12-medium">
       <Switch
         fallback={
           <span>
@@ -243,7 +244,32 @@ export default function SessionTimeline(props: { session: string; class?: string
   const size = createElementSize(root)
   const scroll = createScrollPosition(scrollElement)
 
-  onMount(() => sync.session.sync(props.session))
+  const valid = (part: Part) => {
+    if (!part) return false
+    switch (part.type) {
+      case "step-start":
+      case "step-finish":
+      case "file":
+      case "patch":
+        return false
+      case "text":
+        return !part.synthetic
+      case "reasoning":
+        return part.text.trim()
+      case "tool":
+        switch (part.tool) {
+          case "todoread":
+          case "todowrite":
+          case "list":
+          case "grep":
+            return false
+        }
+        return true
+      default:
+        return true
+    }
+  }
+
   const session = createMemo(() => sync.session.get(props.session))
   const messages = createMemo(() => sync.data.message[props.session] ?? [])
   const working = createMemo(() => {
@@ -253,6 +279,45 @@ export default function SessionTimeline(props: { session: string; class?: string
     return !last.time.completed
   })
 
+  const cost = createMemo(() => {
+    const total = pipe(
+      messages(),
+      sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
+    )
+    return new Intl.NumberFormat("en-US", {
+      style: "currency",
+      currency: "USD",
+    }).format(total)
+  })
+
+  const last = createMemo(() => {
+    return messages().findLast((x) => x.role === "assistant") as AssistantMessage
+  })
+
+  const model = createMemo(() => {
+    if (!last()) return
+    const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
+    return model
+  })
+
+  const tokens = createMemo(() => {
+    if (!last()) return
+    const tokens = last().tokens
+    const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
+    return new Intl.NumberFormat("en-US", {
+      notation: "compact",
+      compactDisplay: "short",
+    }).format(total)
+  })
+
+  const context = createMemo(() => {
+    if (!last()) return
+    if (!model()?.limit.context) return 0
+    const tokens = last().tokens
+    const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
+    return Math.round((total / model()!.limit.context) * 100)
+  })
+
   const getScrollParent = (el: HTMLElement | null): HTMLElement | undefined => {
     let p = el?.parentElement
     while (p && p !== document.body) {
@@ -294,23 +359,6 @@ export default function SessionTimeline(props: { session: string; class?: string
     lastScrollY = scroll.y
   })
 
-  const valid = (part: Part) => {
-    if (!part) return false
-    switch (part.type) {
-      case "step-start":
-      case "step-finish":
-      case "file":
-      case "patch":
-        return false
-      case "text":
-        return !part.synthetic
-      case "reasoning":
-        return part.text.trim()
-      default:
-        return true
-    }
-  }
-
   const duration = (part: Part) => {
     switch (part.type) {
       default:
@@ -334,57 +382,66 @@ export default function SessionTimeline(props: { session: string; class?: string
     <div
       ref={setRoot}
       classList={{
-        "p-4 select-text flex flex-col gap-y-1": true,
+        "select-text flex flex-col text-text-weak": true,
         [props.class ?? ""]: !!props.class,
       }}
     >
-      <ul role="list" class="flex flex-col gap-1">
+      <div class="py-1.5 px-10 flex justify-end items-center self-stretch">
+        <div class="flex items-center gap-6">
+          <Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
+            <Show when={context()}>
+              <ProgressCircle percentage={context()!} />
+            </Show>
+            <div class="text-14-regular text-text-weak text-right">{context()}%</div>
+          </Tooltip>
+          <div class="text-14-regular text-text-strong text-right">{cost()}</div>
+        </div>
+      </div>
+      <ul role="list" class="flex flex-col gap-6 items-start self-stretch px-10 pt-2 pb-6">
         <For each={messages()}>
           {(message) => (
-            <For each={sync.data.part[message.id]?.filter(valid)}>
-              {(part) => (
-                <li class="group/li">
-                  <Switch fallback={<div class="flex-auto min-w-0 text-xs mt-1 text-left">{part.type}</div>}>
-                    <Match when={part.type === "text" && part}>
-                      {(part) => (
-                        <Switch>
-                          <Match when={message.role === "user"}>
-                            <div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0 mt-5 group-first/li:mt-0">
-                              <p class="w-full rounded-md p-3 ring-1 ring-text/15 ring-inset text-xs bg-background-panel">
-                                <span class="font-medium text-text whitespace-pre-wrap break-words">{part().text}</span>
-                              </p>
-                              <p class="text-xs text-text-muted">
-                                {DateTime.fromMillis(message.time.created).toRelative()} ·{" "}
-                                {sync.data.config.username ?? "user"}
-                              </p>
-                            </div>
-                          </Match>
-                          <Match when={message.role === "assistant"}>
-                            <Markdown text={sync.sanitize(part().text)} class="text-text mt-1" />
-                          </Match>
-                        </Switch>
-                      )}
-                    </Match>
-                    <Match when={part.type === "reasoning" && part}>
-                      {(part) => (
-                        <CollapsiblePart
-                          title={
-                            <Switch fallback={<span class="text-text-muted">Thinking</span>}>
-                              <Match when={part().time.end}>
-                                <span class="text-text-muted">Thought</span> for {duration(part())}s
-                              </Match>
-                            </Switch>
-                          }
-                        >
-                          <Markdown text={part().text} />
-                        </CollapsiblePart>
-                      )}
-                    </Match>
-                    <Match when={part.type === "tool" && part}>{(part) => <ToolPart part={part()} />}</Match>
-                  </Switch>
-                </li>
-              )}
-            </For>
+            <div class="flex flex-col gap-1 justify-center items-start self-stretch">
+              <For each={sync.data.part[message.id]?.filter(valid)}>
+                {(part) => (
+                  <li class="group/li">
+                    <Switch fallback={<div class="">{part.type}</div>}>
+                      <Match when={part.type === "text" && part}>
+                        {(part) => (
+                          <Switch>
+                            <Match when={message.role === "user"}>
+                              <div class="w-fit flex items-center px-3 py-1 rounded-md bg-surface-weak">
+                                <span class="text-14-regular text-text-strong whitespace-pre-wrap break-words">
+                                  {part().text}
+                                </span>
+                              </div>
+                            </Match>
+                            <Match when={message.role === "assistant"}>
+                              <Markdown text={sync.sanitize(part().text)} />
+                            </Match>
+                          </Switch>
+                        )}
+                      </Match>
+                      <Match when={part.type === "reasoning" && part}>
+                        {(part) => (
+                          <CollapsiblePart
+                            title={
+                              <Switch fallback={<span class="text-text-weak">Thinking</span>}>
+                                <Match when={part().time.end}>
+                                  <span class="text-12-medium text-text-weak">Thought</span> for {duration(part())}s
+                                </Match>
+                              </Switch>
+                            }
+                          >
+                            <Markdown text={part().text} />
+                          </CollapsiblePart>
+                        )}
+                      </Match>
+                      <Match when={part.type === "tool" && part}>{(part) => <ToolPart part={part()} />}</Match>
+                    </Switch>
+                  </li>
+                )}
+              </For>
+            </div>
           )}
         </For>
       </ul>

+ 7 - 2
packages/desktop/src/pages/index.tsx

@@ -238,7 +238,12 @@ export default function Page() {
                 New Session
               </Button>
             </div>
-            <List data={sync.data.session} key={(x) => x.id} onSelect={(s) => local.session.setActive(s?.id)}>
+            <List
+              data={sync.data.session}
+              key={(x) => x.id}
+              onSelect={(s) => local.session.setActive(s?.id)}
+              onHover={(s) => (!!s ? sync.session.sync(s?.id) : undefined)}
+            >
               {(session) => (
                 <Tooltip placement="right" value={session.title}>
                   <div>
@@ -264,7 +269,7 @@ export default function Page() {
           </div>
         </div>
         <div class="relative grid grid-cols-2 bg-background-base">
-          <div class="min-w-0 overflow-y-auto no-scrollbar flex justify-center">
+          <div class="pt-1.5 min-w-0 overflow-y-auto no-scrollbar flex justify-center">
             <Show when={local.session.active()}>
               {(activeSession) => <SessionTimeline session={activeSession().id} class="w-full" />}
             </Show>

+ 2 - 0
packages/ui/src/components/list.tsx

@@ -9,6 +9,7 @@ export interface ListProps<T> {
   key: (x: T) => string
   current?: T
   onSelect?: (value: T | undefined) => void
+  onHover?: (value: T | undefined) => void
   class?: ComponentProps<"div">["class"]
 }
 
@@ -45,6 +46,7 @@ export function List<T>(props: ListProps<T>) {
   createEffect(() => {
     if (store.mouseActive || props.data.length === 0) return
     const index = props.data.findIndex((x) => props.key(x) === list.active())
+    props.onHover?.(props.data[index])
     if (index === 0) {
       virtualizer()?.scrollTo(0)
       return

+ 2 - 2
packages/ui/src/components/tooltip.tsx

@@ -30,11 +30,11 @@ export function Tooltip(props: TooltipProps) {
 
   return (
     <KobalteTooltip forceMount {...others} open={open()} onOpenChange={setOpen}>
-      <KobalteTooltip.Trigger as={"div"} data-component="tooltip-trigger">
+      <KobalteTooltip.Trigger as={"div"} data-component="tooltip-trigger" class={local.class}>
         {c()}
       </KobalteTooltip.Trigger>
       <KobalteTooltip.Portal>
-        <KobalteTooltip.Content data-component="tooltip" data-placement={props.placement} class={local.class}>
+        <KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}>
           {typeof others.value === "function" ? others.value() : others.value}
           {/* <KobalteTooltip.Arrow data-slot="arrow" size={18} /> */}
         </KobalteTooltip.Content>

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

@@ -32,6 +32,15 @@
   --tracking-tight: var(--letter-spacing-tight);
   --tracking-tightest: var(--letter-spacing-tightest);
 
+  --radius-xs: 0.125rem;
+  --radius-sm: 0.25rem;
+  --radius-md: 0.375rem;
+  --radius-lg: 0.5rem;
+  --radius-xl: 0.75rem;
+  --radius-2xl: 1rem;
+  --radius-3xl: 1.5rem;
+  --radius-4xl: 2rem;
+
   --shadow-xs: var(--shadow-xs);
   --shadow-md: var(--shadow-md);
   --shadow-xs-border-selected: var(--shadow-xs-border-selected);

+ 1 - 1
packages/ui/src/styles/theme.css

@@ -277,7 +277,7 @@
   --markdown-code-block: #1a1a1a;
   --border-color: #ffffff;
 
-  .dark {
+  @media (prefers-color-scheme: dark) {
     /* OC-1-Dark */
     color-scheme: dark;
     --background-base: var(--smoke-dark-1);