summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-10-17 15:22:08 -0500
committerAdam <[email protected]>2025-10-17 15:22:11 -0500
commit335d83365521728181248b13a55386a10ae41ef0 (patch)
tree2400f15608681c84777c3f9a897552cdbdc206a5
parent1dba01e0577eb2012e3b6fe99b3a171875c6dab8 (diff)
downloadopencode-335d83365521728181248b13a55386a10ae41ef0.tar.gz
opencode-335d83365521728181248b13a55386a10ae41ef0.zip
wip: desktop work
-rw-r--r--packages/desktop/index.html20
-rw-r--r--packages/desktop/src/components/markdown.tsx2
-rw-r--r--packages/desktop/src/components/progress-circle.tsx48
-rw-r--r--packages/desktop/src/components/session-timeline.tsx223
-rw-r--r--packages/desktop/src/pages/index.tsx9
-rw-r--r--packages/ui/src/components/list.tsx2
-rw-r--r--packages/ui/src/components/tooltip.tsx4
-rw-r--r--packages/ui/src/styles/tailwind/index.css9
-rw-r--r--packages/ui/src/styles/theme.css2
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);