From 30f4c2cf4c6c01339434c617fb9d930f6e960883 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:26:06 -0500 Subject: wip: desktop work --- packages/desktop/src/components/diff-changes.tsx | 20 - packages/desktop/src/components/message.tsx | 253 ++-------- .../desktop/src/components/session-timeline.tsx | 536 --------------------- packages/desktop/src/pages/index.tsx | 9 +- 4 files changed, 41 insertions(+), 777 deletions(-) delete mode 100644 packages/desktop/src/components/diff-changes.tsx delete mode 100644 packages/desktop/src/components/session-timeline.tsx (limited to 'packages/desktop/src') diff --git a/packages/desktop/src/components/diff-changes.tsx b/packages/desktop/src/components/diff-changes.tsx deleted file mode 100644 index 3b633f70f..000000000 --- a/packages/desktop/src/components/diff-changes.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { FileDiff } from "@opencode-ai/sdk" -import { createMemo, Show } from "solid-js" - -export function DiffChanges(props: { diff: FileDiff | FileDiff[] }) { - const additions = createMemo(() => - Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) : props.diff.additions, - ) - const deletions = createMemo(() => - Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) : props.diff.deletions, - ) - const total = createMemo(() => additions() + deletions()) - return ( - 0}> -
- {`+${additions()}`} - {`-${deletions()}`} -
-
- ) -} diff --git a/packages/desktop/src/components/message.tsx b/packages/desktop/src/components/message.tsx index 589ca3118..9e9e06d35 100644 --- a/packages/desktop/src/components/message.tsx +++ b/packages/desktop/src/components/message.tsx @@ -1,238 +1,57 @@ -import type { Part, ReasoningPart, TextPart, ToolPart, Message, AssistantMessage, UserMessage } from "@opencode-ai/sdk" -import { children, Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js" +import type { Part, TextPart, ToolPart, Message } from "@opencode-ai/sdk" +import { createMemo, For, Show } from "solid-js" import { Dynamic } from "solid-js/web" import { Markdown } from "./markdown" -import { Checkbox, Collapsible, Diff, Icon, IconProps } from "@opencode-ai/ui" +import { Checkbox, Diff, Icon } from "@opencode-ai/ui" +import { Message as MessageDisplay, registerPartComponent } from "@opencode-ai/ui" +import { BasicTool, GenericTool, ToolRegistry, DiffChanges } from "@opencode-ai/ui" import { getDirectory, getFilename } from "@/utils" -import type { Tool } from "opencode/tool/tool" -import type { ReadTool } from "opencode/tool/read" -import type { ListTool } from "opencode/tool/ls" -import type { GlobTool } from "opencode/tool/glob" -import type { GrepTool } from "opencode/tool/grep" -import type { WebFetchTool } from "opencode/tool/webfetch" -import type { TaskTool } from "opencode/tool/task" -import type { BashTool } from "opencode/tool/bash" -import type { EditTool } from "opencode/tool/edit" -import type { WriteTool } from "opencode/tool/write" -import type { TodoWriteTool } from "opencode/tool/todo" -import { DiffChanges } from "./diff-changes" export function Message(props: { message: Message; parts: Part[] }) { - return ( - - - {(userMessage) => } - - - {(assistantMessage) => } - - - ) + return } -function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) { - const filteredParts = createMemo(() => { - return props.parts?.filter((x) => { - if (x.type === "reasoning") return false - return x.type !== "tool" || x.tool !== "todoread" - }) - }) +registerPartComponent("text", function TextPartDisplay(props) { + const part = props.part as TextPart return ( -
- {(part) => } -
- ) -} - -function UserMessage(props: { message: UserMessage; parts: Part[] }) { - const text = createMemo(() => - props.parts - ?.filter((p) => p.type === "text" && !p.synthetic) - ?.map((p) => (p as TextPart).text) - ?.join(""), - ) - return
{text()}
-} - -export function Part(props: { part: Part; message: Message; hideDetails?: boolean }) { - const component = createMemo(() => PART_MAPPING[props.part.type as keyof typeof PART_MAPPING]) - return ( - - - - ) -} - -const PART_MAPPING = { - text: TextPart, - tool: ToolPart, - reasoning: ReasoningPart, -} - -function ReasoningPart(props: { part: ReasoningPart; message: Message }) { - return ( - - + + ) -} +}) -function TextPart(props: { part: TextPart; message: Message }) { +registerPartComponent("reasoning", function ReasoningPartDisplay(props) { + const part = props.part as any return ( - - + + ) -} +}) -function ToolPart(props: { part: ToolPart; message: Message; hideDetails?: boolean }) { +registerPartComponent("tool", function ToolPartDisplay(props) { + const part = props.part as ToolPart const component = createMemo(() => { - const render = ToolRegistry.render(props.part.tool) ?? GenericTool - const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) - const input = props.part.state.status === "completed" ? props.part.state.input : {} + const render = ToolRegistry.render(part.tool) ?? GenericTool + const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {}) + const input = part.state.status === "completed" ? part.state.input : {} return ( ) }) return {component()} -} - -type TriggerTitle = { - title: string - titleClass?: string - subtitle?: string - subtitleClass?: string - args?: string[] - argsClass?: string - action?: JSX.Element -} - -const isTriggerTitle = (val: any): val is TriggerTitle => { - return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node) -} - -function BasicTool(props: { - icon: IconProps["name"] - trigger: TriggerTitle | JSX.Element - children?: JSX.Element - hideDetails?: boolean -}) { - const resolved = children(() => props.children) - return ( - - -
-
- -
- - - {(trigger) => ( -
-
- - {trigger().title} - - - - {trigger().subtitle} - - - - - {(arg) => ( - - {arg} - - )} - - -
- {trigger().action} -
- )} -
- {props.trigger as JSX.Element} -
-
-
- - - -
-
- - {resolved()} - -
- // <> - // {props.part.state.error.replace("Error: ", "")} - // - ) -} - -function GenericTool(props: ToolProps) { - return -} - -type ToolProps = { - input: Partial> - metadata: Partial> - tool: string - output?: string - hideDetails?: boolean -} - -const ToolRegistry = (() => { - const state: Record< - string, - { - name: string - render?: Component> - } - > = {} - function register(input: { name: string; render?: Component> }) { - state[input.name] = input - return input - } - return { - register, - render(name: string) { - return state[name]?.render - }, - } -})() +}) -ToolRegistry.register({ +ToolRegistry.register({ name: "read", render(props) { return ( @@ -244,7 +63,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "list", render(props) { return ( @@ -257,7 +76,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "glob", render(props) { return ( @@ -277,7 +96,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "grep", render(props) { const args = [] @@ -300,7 +119,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "webfetch", render(props) { return ( @@ -325,7 +144,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "task", render(props) { return ( @@ -345,7 +164,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "bash", render(props) { return ( @@ -364,7 +183,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "edit", render(props) { return ( @@ -402,7 +221,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "write", render(props) { return ( @@ -431,7 +250,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "todowrite", render(props) { return ( @@ -439,13 +258,13 @@ ToolRegistry.register({ icon="checklist" trigger={{ title: "To-dos", - subtitle: `${props.input.todos?.filter((t) => t.status === "completed").length}/${props.input.todos?.length}`, + subtitle: `${props.input.todos?.filter((t: any) => t.status === "completed").length}/${props.input.todos?.length}`, }} >
- {(todo) => ( + {(todo: any) => (
{todo.content}
diff --git a/packages/desktop/src/components/session-timeline.tsx b/packages/desktop/src/components/session-timeline.tsx deleted file mode 100644 index e1f3beae4..000000000 --- a/packages/desktop/src/components/session-timeline.tsx +++ /dev/null @@ -1,536 +0,0 @@ -import { Icon, Tooltip } from "@opencode-ai/ui" -import { Collapsible } from "@/ui" -import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk" -import { DateTime } from "luxon" -import { - createSignal, - For, - Match, - splitProps, - Switch, - type ComponentProps, - type ParentProps, - createEffect, - createMemo, - Show, -} from "solid-js" -import { getFilename } from "@/utils" -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" -import { useSync } from "@/context/sync" -import { useLocal } from "@/context/local" - -function Part(props: ParentProps & ComponentProps<"div">) { - const [local, others] = splitProps(props, ["class", "classList", "children"]) - return ( -
-

{local.children}

-
- ) -} - -function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps & ComponentProps) { - return ( - - - {props.title} - - -

- {props.children} -

-
-
- ) -} - -function ReadToolPart(props: { part: ToolPart }) { - const sync = useSync() - const local = useLocal() - return ( - - - Reading file... - - - {(state) => { - const path = state().input["filePath"] as string - return ( - local.file.open(path)}> - Read {getFilename(path)} - - ) - }} - - - {(state) => ( -
- - Read {getFilename(state().input["filePath"] as string)} - -
{sync.sanitize(state().error)}
-
- )} -
-
- ) -} - -function EditToolPart(props: { part: ToolPart }) { - const sync = useSync() - return ( - - - Preparing edit... - - - {(state) => ( - - Edit {getFilename(state().input["filePath"] as string)} - - } - > - - - )} - - - {(state) => ( - - Edit {getFilename(state().input["filePath"] as string)} - - } - > -
{sync.sanitize(state().error)}
-
- )} -
-
- ) -} - -function WriteToolPart(props: { part: ToolPart }) { - const sync = useSync() - return ( - - - Preparing write... - - - {(state) => ( - - Write {getFilename(state().input["filePath"] as string)} - - } - > -
-
- )} -
- - {(state) => ( -
- - Write {getFilename(state().input["filePath"] as string)} - -
{sync.sanitize(state().error)}
-
- )} -
-
- ) -} - -function BashToolPart(props: { part: ToolPart }) { - const sync = useSync() - return ( - - - Writing shell command... - - - {(state) => ( - - Run command: {state().input["command"]} - - } - > - - - )} - - - {(state) => ( - - Shell {state().input["command"]} - - } - > -
{sync.sanitize(state().error)}
-
- )} -
-
- ) -} - -function ToolPart(props: { part: ToolPart }) { - // read - // edit - // write - // bash - // ls - // glob - // grep - // todowrite - // todoread - // webfetch - // websearch - // patch - // task - return ( -
- - {props.part.type}:{props.part.tool} - - } - > - - - - - - - - - - - - - -
- ) -} - -export default function SessionTimeline(props: { session: string; class?: string }) { - const sync = useSync() - const [scrollElement, setScrollElement] = createSignal(undefined) - const [root, setRoot] = createSignal(undefined) - const [tail, setTail] = createSignal(true) - const size = createElementSize(root) - const scroll = createScrollPosition(scrollElement) - - 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 && part.text.trim() - 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 hasValidParts = (message: Message) => { - return sync.data.part[message.id]?.filter(valid).length > 0 - } - - const hasTextPart = (message: Message) => { - return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text") - } - - const session = createMemo(() => sync.session.get(props.session)) - const messages = createMemo(() => sync.data.message[props.session] ?? []) - const messagesWithValidParts = createMemo(() => sync.data.message[props.session]?.filter(hasValidParts) ?? []) - const working = createMemo(() => { - const last = messages()[messages().length - 1] - if (!last) return false - if (last.role === "user") return true - 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) { - const s = getComputedStyle(p) - if (s.overflowY === "auto" || s.overflowY === "scroll") return p - p = p.parentElement - } - return undefined - } - - createEffect(() => { - if (!root()) return - setScrollElement(getScrollParent(root()!)) - }) - - const scrollToBottom = () => { - const element = scrollElement() - if (!element) return - element.scrollTop = element.scrollHeight - } - - createEffect(() => { - size.height - if (tail()) scrollToBottom() - }) - - createEffect(() => { - if (working()) { - setTail(true) - scrollToBottom() - } - }) - - let lastScrollY = 0 - createEffect(() => { - if (scroll.y < lastScrollY) { - setTail(false) - } - lastScrollY = scroll.y - }) - - const duration = (part: Part) => { - switch (part.type) { - default: - if ( - "time" in part && - part.time && - "start" in part.time && - part.time.start && - "end" in part.time && - part.time.end - ) { - const start = DateTime.fromMillis(part.time.start) - const end = DateTime.fromMillis(part.time.end) - return end.diff(start).toFormat("s") - } - return "" - } - } - - createEffect(() => { - console.log("WHAT") - console.log(JSON.stringify(messagesWithValidParts())) - }) - - return ( -
-
-
- - - - -
{context()}%
-
-
{cost()}
-
-
-
    - - {(message) => ( -
    - - {(part) => ( -
  • - {part.type}
  • }> - - {(part) => ( - - -
    - - {part().text} - -
    -
    - - - -
    - )} -
    - - {(part) => ( - Thinking}> - - Thought for {duration(part())}s - - - } - > - - - )} - - {(part) => } - - - )} -
    -
- )} -
- - - - -
- - Raw Session Data - -
-
- -
    -
  • - - -
    - - session - -
    -
    - - - -
    -
  • - - {(message) => ( - <> -
  • - - -
    - - {message.role === "user" ? "user" : "assistant"} - -
    -
    - - - -
    -
  • - - {(part) => ( -
  • - - -
    - - {part.type} - -
    -
    - - - -
    -
  • - )} -
    - - )} -
    -
-
-
-
-
- ) -} diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 5216c4272..2a6761623 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -9,6 +9,7 @@ import { Accordion, Diff, Collapsible, + Part, } from "@opencode-ai/ui" import { FileIcon } from "@/ui" import FileTree from "@/components/file-tree" @@ -33,9 +34,9 @@ import { Code } from "@/components/code" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { ProgressCircle } from "@/components/progress-circle" -import { Message, Part } from "@/components/message" +import { Message } from "@/components/message" import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" -import { DiffChanges } from "@/components/diff-changes" +import { DiffChanges } from "@opencode-ai/ui" import { Markdown } from "@/components/markdown" export default function Page() { @@ -497,7 +498,7 @@ export default function Page() { +
New session
@@ -660,7 +661,7 @@ export default function Page() { class="flex flex-col items-start self-stretch gap-8 min-h-screen" > {/* Title */} -
+

{title() ?? prompt()}

-- cgit v1.2.3