diff --git a/packages/desktop/index.html b/packages/desktop/index.html index c6c54359..c591cb46 100644 --- a/packages/desktop/index.html +++ b/packages/desktop/index.html @@ -1,5 +1,5 @@ - + @@ -7,15 +7,15 @@ OpenCode - - + + + + + + + + +
diff --git a/packages/desktop/src/components/markdown.tsx b/packages/desktop/src/components/markdown.tsx index a60fad14..30e3831e 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 (
) diff --git a/packages/desktop/src/components/progress-circle.tsx b/packages/desktop/src/components/progress-circle.tsx new file mode 100644 index 00000000..d56197ed --- /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 = (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 ( + + + + + ) +} diff --git a/packages/desktop/src/components/session-timeline.tsx b/packages/desktop/src/components/session-timeline.tsx index d4adf2e4..2474b310 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} > -

{local.children}

+

{local.children}

) } @@ -45,8 +47,8 @@ function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps {props.title} -

- {props.children} +

+ {props.children}

@@ -66,7 +68,7 @@ function ReadToolPart(props: { part: ToolPart }) { const path = state().input["filePath"] as string return ( local.file.open(path)}> - Read {getFilename(path)} + Read {getFilename(path)} ) }} @@ -75,9 +77,9 @@ function ReadToolPart(props: { part: ToolPart }) { {(state) => (
- Read {getFilename(state().input["filePath"] as string)} + Read {getFilename(state().input["filePath"] as string)} -
{sync.sanitize(state().error)}
+
{sync.sanitize(state().error)}
)} @@ -95,10 +97,9 @@ function EditToolPart(props: { part: ToolPart }) { {(state) => ( - Edit {getFilename(state().input["filePath"] as string)} + Edit {getFilename(state().input["filePath"] as string)} } > @@ -111,11 +112,11 @@ function EditToolPart(props: { part: ToolPart }) { - Edit {getFilename(state().input["filePath"] as string)} + Edit {getFilename(state().input["filePath"] as string)} } > -
{sync.sanitize(state().error)}
+
{sync.sanitize(state().error)}
)}
@@ -135,7 +136,7 @@ function WriteToolPart(props: { part: ToolPart }) { - Write {getFilename(state().input["filePath"] as string)} + Write {getFilename(state().input["filePath"] as string)} } > @@ -147,9 +148,9 @@ function WriteToolPart(props: { part: ToolPart }) { {(state) => (
- Write {getFilename(state().input["filePath"] as string)} + Write {getFilename(state().input["filePath"] as string)} -
{sync.sanitize(state().error)}
+
{sync.sanitize(state().error)}
)} @@ -170,7 +171,7 @@ function BashToolPart(props: { part: ToolPart }) { defaultOpen title={ <> - Run command: {state().input["command"]} + Run command: {state().input["command"]} } > @@ -183,11 +184,11 @@ function BashToolPart(props: { part: ToolPart }) { - Shell {state().input["command"]} + Shell {state().input["command"]} } > -
{sync.sanitize(state().error)}
+
{sync.sanitize(state().error)}
)} @@ -210,7 +211,7 @@ function ToolPart(props: { part: ToolPart }) { // patch // task return ( -
+
@@ -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
-
    +
    +
    + + + + +
    {context()}%
    +
    +
    {cost()}
    +
    +
    +
      {(message) => ( - - {(part) => ( -
    • - {part.type}
}> - - {(part) => ( - - -
-

- {part().text} -

-

- {DateTime.fromMillis(message.time.created).toRelative()} ยท{" "} - {sync.data.config.username ?? "user"} -

-
-
- - - -
- )} -
- - {(part) => ( - Thinking}> - - Thought for {duration(part())}s - -
- } - > - - - )} - - {(part) => } - - - )} - +
+ + {(part) => ( +
  • + {part.type}
  • }> + + {(part) => ( + + +
    + + {part().text} + +
    +
    + + + +
    + )} +
    + + {(part) => ( + Thinking}> + + Thought for {duration(part())}s + + + } + > + + + )} + + {(part) => } + + + )} + +
    )} diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 4133887c..80473d84 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
    - x.id} onSelect={(s) => local.session.setActive(s?.id)}> + x.id} + onSelect={(s) => local.session.setActive(s?.id)} + onHover={(s) => (!!s ? sync.session.sync(s?.id) : undefined)} + > {(session) => (
    @@ -264,7 +269,7 @@ export default function Page() {
    -
    +
    {(activeSession) => } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 9704e455..8bfbbdc9 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -9,6 +9,7 @@ export interface ListProps { 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(props: ListProps) { 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 b975099f..14e433e2 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 ( - + {c()} - + {typeof others.value === "function" ? others.value() : others.value} {/* */} diff --git a/packages/ui/src/styles/tailwind/index.css b/packages/ui/src/styles/tailwind/index.css index 9faa3f97..e8e9641b 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 ccfebd4c..5358f380 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);