diff options
| author | Adam <[email protected]> | 2025-10-17 15:22:08 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-10-17 15:22:11 -0500 |
| commit | 335d83365521728181248b13a55386a10ae41ef0 (patch) | |
| tree | 2400f15608681c84777c3f9a897552cdbdc206a5 | |
| parent | 1dba01e0577eb2012e3b6fe99b3a171875c6dab8 (diff) | |
| download | opencode-335d83365521728181248b13a55386a10ae41ef0.tar.gz opencode-335d83365521728181248b13a55386a10ae41ef0.zip | |
wip: desktop work
| -rw-r--r-- | packages/desktop/index.html | 20 | ||||
| -rw-r--r-- | packages/desktop/src/components/markdown.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/components/progress-circle.tsx | 48 | ||||
| -rw-r--r-- | packages/desktop/src/components/session-timeline.tsx | 223 | ||||
| -rw-r--r-- | packages/desktop/src/pages/index.tsx | 9 | ||||
| -rw-r--r-- | packages/ui/src/components/list.tsx | 2 | ||||
| -rw-r--r-- | packages/ui/src/components/tooltip.tsx | 4 | ||||
| -rw-r--r-- | packages/ui/src/styles/tailwind/index.css | 9 | ||||
| -rw-r--r-- | packages/ui/src/styles/theme.css | 2 |
9 files changed, 220 insertions, 99 deletions
diff --git a/packages/desktop/index.html b/packages/desktop/index.html index c6c543591..c591cb46c 100644 --- a/packages/desktop/index.html +++ b/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> diff --git a/packages/desktop/src/components/markdown.tsx b/packages/desktop/src/components/markdown.tsx index a60fad149..30e3831e3 100644 --- a/packages/desktop/src/components/markdown.tsx +++ b/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()} /> ) diff --git a/packages/desktop/src/components/progress-circle.tsx b/packages/desktop/src/components/progress-circle.tsx new file mode 100644 index 000000000..d56197ed3 --- /dev/null +++ b/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> + ) +} diff --git a/packages/desktop/src/components/session-timeline.tsx b/packages/desktop/src/components/session-timeline.tsx index d4adf2e4a..2474b3101 100644 --- a/packages/desktop/src/components/session-timeline.tsx +++ b/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> diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 4133887c9..80473d84a 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/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> diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 9704e4554..8bfbbdc98 100644 --- a/packages/ui/src/components/list.tsx +++ b/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 diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index b975099fb..14e433e21 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/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> diff --git a/packages/ui/src/styles/tailwind/index.css b/packages/ui/src/styles/tailwind/index.css index 9faa3f970..e8e9641b4 100644 --- a/packages/ui/src/styles/tailwind/index.css +++ b/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); diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index ccfebd4c2..5358f380d 100644 --- a/packages/ui/src/styles/theme.css +++ b/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); |
