diff options
| author | Adam <[email protected]> | 2025-10-30 07:26:06 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-10-30 12:02:49 -0500 |
| commit | 30f4c2cf4c6c01339434c617fb9d930f6e960883 (patch) | |
| tree | db5da342a227724e11609e05f9e3c1fd6e2e7741 | |
| parent | 3541fdcb2019676fb82351e909a8e9b740cb8237 (diff) | |
| download | opencode-30f4c2cf4c6c01339434c617fb9d930f6e960883.tar.gz opencode-30f4c2cf4c6c01339434c617fb9d930f6e960883.zip | |
wip: desktop work
| -rw-r--r-- | bun.lock | 19 | ||||
| -rw-r--r-- | packages/desktop/package.json | 3 | ||||
| -rw-r--r-- | packages/desktop/src/components/diff-changes.tsx | 20 | ||||
| -rw-r--r-- | packages/desktop/src/components/message.tsx | 253 | ||||
| -rw-r--r-- | packages/desktop/src/components/session-timeline.tsx | 536 | ||||
| -rw-r--r-- | packages/desktop/src/pages/index.tsx | 9 | ||||
| -rw-r--r-- | packages/ui/package.json | 2 | ||||
| -rw-r--r-- | packages/ui/src/components/collapsible.css | 2 | ||||
| -rw-r--r-- | packages/ui/src/components/diff-changes.css | 28 | ||||
| -rw-r--r-- | packages/ui/src/components/diff-changes.tsx | 24 | ||||
| -rw-r--r-- | packages/ui/src/components/index.ts | 4 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.css | 22 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 87 | ||||
| -rw-r--r-- | packages/ui/src/components/tool-display.css | 76 | ||||
| -rw-r--r-- | packages/ui/src/components/tool-display.tsx | 95 | ||||
| -rw-r--r-- | packages/ui/src/components/tool-registry.tsx | 33 | ||||
| -rw-r--r-- | packages/ui/src/styles/index.css | 3 |
17 files changed, 427 insertions, 789 deletions
@@ -114,7 +114,6 @@ "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", - "@pierre/precision-diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/event-bus": "1.1.2", @@ -141,7 +140,6 @@ "@types/luxon": "3.7.1", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", - "opencode": "workspace:*", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", @@ -281,6 +279,7 @@ "version": "0.15.29", "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/sdk": "workspace:*", "@pierre/precision-diffs": "catalog:", "@solidjs/meta": "catalog:", "fuzzysort": "catalog:", @@ -1080,7 +1079,7 @@ "@selderee/plugin-htmlparser2": ["@selderee/[email protected]", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], - "@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="], + "@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], "@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-kUTRVKPsB/28H5Ko6qEsyudBiWEDLst+Sfi+hwr59E0GLHV0h8RfgbQU7fdN5Lt9A8R1ulRiZyTvAizkROjwDA=="], @@ -3518,6 +3517,8 @@ "@parcel/watcher-wasm/napi-wasm": ["[email protected]", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="], + "@pierre/precision-diffs/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="], + "@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="], "@pierre/precision-diffs/shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="], @@ -3530,10 +3531,6 @@ "@rollup/pluginutils/estree-walker": ["[email protected]", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@shikijs/core/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="], - - "@shikijs/transformers/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], - "@slack/bolt/path-to-regexp": ["[email protected]", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "@slack/oauth/@slack/logger": ["@slack/[email protected]", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], @@ -3798,8 +3795,6 @@ "send/mime": ["[email protected]", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - "shiki/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], - "sitemap/sax": ["[email protected]", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], "source-map-support/source-map": ["[email protected]", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -3954,6 +3949,8 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="], + "@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="], "@expressive-code/plugin-shiki/shiki/@shikijs/engine-oniguruma": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="], @@ -4088,6 +4085,8 @@ "@opencode-ai/web/shiki/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="], + "@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="], + "@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="], "@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="], @@ -4350,6 +4349,8 @@ "@actions/github/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/[email protected]", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="], + "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="], "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="], diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 400815215..32fe27b83 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -4,6 +4,7 @@ "description": "", "type": "module", "scripts": { + "typecheck": "tsgo --noEmit", "start": "vite", "dev": "vite", "build": "vite build", @@ -11,7 +12,6 @@ }, "license": "MIT", "devDependencies": { - "opencode": "workspace:*", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/luxon": "3.7.1", @@ -26,7 +26,6 @@ "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", - "@pierre/precision-diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/event-bus": "1.1.2", 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 ( - <Show when={total() > 0}> - <div class="flex gap-2 justify-end items-center"> - <span class="text-12-mono text-right text-text-diff-add-base">{`+${additions()}`}</span> - <span class="text-12-mono text-right text-text-diff-delete-base">{`-${deletions()}`}</span> - </div> - </Show> - ) -} 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 ( - <Switch> - <Match when={props.message.role === "user" && props.message}> - {(userMessage) => <UserMessage message={userMessage()} parts={props.parts} />} - </Match> - <Match when={props.message.role === "assistant" && props.message}> - {(assistantMessage) => <AssistantMessage message={assistantMessage()} parts={props.parts} />} - </Match> - </Switch> - ) + return <MessageDisplay message={props.message} parts={props.parts} /> } -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 ( - <div class="w-full flex flex-col items-start gap-4"> - <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For> - </div> - ) -} - -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 <div class="text-12-regular text-text-base line-clamp-3">{text()}</div> -} - -export function Part(props: { part: Part; message: Message; hideDetails?: boolean }) { - const component = createMemo(() => PART_MAPPING[props.part.type as keyof typeof PART_MAPPING]) - return ( - <Show when={component()}> - <Dynamic - component={component()} - part={props.part as any} - message={props.message} - hideDetails={props.hideDetails} - /> - </Show> - ) -} - -const PART_MAPPING = { - text: TextPart, - tool: ToolPart, - reasoning: ReasoningPart, -} - -function ReasoningPart(props: { part: ReasoningPart; message: Message }) { - return ( - <Show when={props.part.text.trim()}> - <Markdown text={props.part.text.trim()} /> + <Show when={part.text.trim()}> + <Markdown text={part.text.trim()} /> </Show> ) -} +}) -function TextPart(props: { part: TextPart; message: Message }) { +registerPartComponent("reasoning", function ReasoningPartDisplay(props) { + const part = props.part as any return ( - <Show when={props.part.text.trim()}> - <Markdown text={props.part.text.trim()} /> + <Show when={part.text.trim()}> + <Markdown text={part.text.trim()} /> </Show> ) -} +}) -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 ( <Dynamic component={render} input={input} - tool={props.part.tool} + tool={part.tool} metadata={metadata} - output={props.part.state.status === "completed" ? props.part.state.output : undefined} + output={part.state.status === "completed" ? part.state.output : undefined} hideDetails={props.hideDetails} /> ) }) return <Show when={component()}>{component()}</Show> -} - -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 ( - <Collapsible> - <Collapsible.Trigger> - <div class="w-full flex items-center self-stretch gap-5 justify-between"> - <div class="w-full flex items-center self-stretch gap-5"> - <Icon name={props.icon} size="small" class="shrink-0" /> - <div class="grow min-w-0"> - <Switch> - <Match when={isTriggerTitle(props.trigger) && props.trigger}> - {(trigger) => ( - <div class="w-full flex items-center gap-2 justify-between"> - <div class="flex items-center gap-2 whitespace-nowrap truncate"> - <span - classList={{ - "text-12-medium text-text-base": true, - [trigger().titleClass ?? ""]: !!trigger().titleClass, - }} - > - {trigger().title} - </span> - <Show when={trigger().subtitle}> - <span - classList={{ - "text-12-medium text-text-weak": true, - [trigger().subtitleClass ?? ""]: !!trigger().subtitleClass, - }} - > - {trigger().subtitle} - </span> - </Show> - <Show when={trigger().args?.length}> - <For each={trigger().args}> - {(arg) => ( - <span - classList={{ - "text-12-regular text-text-weak": true, - [trigger().argsClass ?? ""]: !!trigger().argsClass, - }} - > - {arg} - </span> - )} - </For> - </Show> - </div> - <Show when={trigger().action}>{trigger().action}</Show> - </div> - )} - </Match> - <Match when={true}>{props.trigger as JSX.Element}</Match> - </Switch> - </div> - </div> - <Show when={resolved() && !props.hideDetails}> - <Collapsible.Arrow /> - </Show> - </div> - </Collapsible.Trigger> - <Show when={resolved() && !props.hideDetails}> - <Collapsible.Content>{resolved()}</Collapsible.Content> - </Show> - </Collapsible> - // <> - // <Show when={props.part.state.status === "error"}>{props.part.state.error.replace("Error: ", "")}</Show> - // </> - ) -} - -function GenericTool(props: ToolProps<any>) { - return <BasicTool icon="mcp" trigger={{ title: props.tool }} hideDetails={props.hideDetails} /> -} - -type ToolProps<T extends Tool.Info> = { - input: Partial<Tool.InferParameters<T>> - metadata: Partial<Tool.InferMetadata<T>> - tool: string - output?: string - hideDetails?: boolean -} - -const ToolRegistry = (() => { - const state: Record< - string, - { - name: string - render?: Component<ToolProps<any>> - } - > = {} - function register<T extends Tool.Info>(input: { name: string; render?: Component<ToolProps<T>> }) { - state[input.name] = input - return input - } - return { - register, - render(name: string) { - return state[name]?.render - }, - } -})() +}) -ToolRegistry.register<typeof ReadTool>({ +ToolRegistry.register({ name: "read", render(props) { return ( @@ -244,7 +63,7 @@ ToolRegistry.register<typeof ReadTool>({ }, }) -ToolRegistry.register<typeof ListTool>({ +ToolRegistry.register({ name: "list", render(props) { return ( @@ -257,7 +76,7 @@ ToolRegistry.register<typeof ListTool>({ }, }) -ToolRegistry.register<typeof GlobTool>({ +ToolRegistry.register({ name: "glob", render(props) { return ( @@ -277,7 +96,7 @@ ToolRegistry.register<typeof GlobTool>({ }, }) -ToolRegistry.register<typeof GrepTool>({ +ToolRegistry.register({ name: "grep", render(props) { const args = [] @@ -300,7 +119,7 @@ ToolRegistry.register<typeof GrepTool>({ }, }) -ToolRegistry.register<typeof WebFetchTool>({ +ToolRegistry.register({ name: "webfetch", render(props) { return ( @@ -325,7 +144,7 @@ ToolRegistry.register<typeof WebFetchTool>({ }, }) -ToolRegistry.register<typeof TaskTool>({ +ToolRegistry.register({ name: "task", render(props) { return ( @@ -345,7 +164,7 @@ ToolRegistry.register<typeof TaskTool>({ }, }) -ToolRegistry.register<typeof BashTool>({ +ToolRegistry.register({ name: "bash", render(props) { return ( @@ -364,7 +183,7 @@ ToolRegistry.register<typeof BashTool>({ }, }) -ToolRegistry.register<typeof EditTool>({ +ToolRegistry.register({ name: "edit", render(props) { return ( @@ -402,7 +221,7 @@ ToolRegistry.register<typeof EditTool>({ }, }) -ToolRegistry.register<typeof WriteTool>({ +ToolRegistry.register({ name: "write", render(props) { return ( @@ -431,7 +250,7 @@ ToolRegistry.register<typeof WriteTool>({ }, }) -ToolRegistry.register<typeof TodoWriteTool>({ +ToolRegistry.register({ name: "todowrite", render(props) { return ( @@ -439,13 +258,13 @@ ToolRegistry.register<typeof TodoWriteTool>({ 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}`, }} > <Show when={props.input.todos?.length}> <div class="px-12 pt-2.5 pb-6 flex flex-col gap-2"> <For each={props.input.todos}> - {(todo) => ( + {(todo: any) => ( <Checkbox readOnly checked={todo.status === "completed"}> <div classList={{ "line-through text-text-weaker": todo.status === "completed" }}>{todo.content}</div> </Checkbox> 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 ( - <div - classList={{ - ...(local.classList ?? {}), - "h-6 flex items-center": true, - [local.class ?? ""]: !!local.class, - }} - {...others} - > - <p class="text-12-medium text-left">{local.children}</p> - </div> - ) -} - -function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps & ComponentProps<typeof Collapsible>) { - return ( - <Collapsible {...props}> - <Collapsible.Trigger class="peer/collapsible"> - <Part>{props.title}</Part> - </Collapsible.Trigger> - <Collapsible.Content> - <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> - ) -} - -function ReadToolPart(props: { part: ToolPart }) { - const sync = useSync() - const local = useLocal() - return ( - <Switch> - <Match when={props.part.state.status === "pending"}> - <Part>Reading file...</Part> - </Match> - <Match when={props.part.state.status === "completed" && props.part.state}> - {(state) => { - const path = state().input["filePath"] as string - return ( - <Part onClick={() => local.file.open(path)}> - <span class="">Read</span> {getFilename(path)} - </Part> - ) - }} - </Match> - <Match when={props.part.state.status === "error" && props.part.state}> - {(state) => ( - <div> - <Part> - <span class="">Read</span> {getFilename(state().input["filePath"] as string)} - </Part> - <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div> - </div> - )} - </Match> - </Switch> - ) -} - -function EditToolPart(props: { part: ToolPart }) { - const sync = useSync() - return ( - <Switch> - <Match when={props.part.state.status === "pending"}> - <Part>Preparing edit...</Part> - </Match> - <Match when={props.part.state.status === "completed" && props.part.state}> - {(state) => ( - <CollapsiblePart - title={ - <> - <span class="">Edit</span> {getFilename(state().input["filePath"] as string)} - </> - } - > - <Code path={state().input["filePath"] as string} code={state().metadata["diff"] as string} /> - </CollapsiblePart> - )} - </Match> - <Match when={props.part.state.status === "error" && props.part.state}> - {(state) => ( - <CollapsiblePart - title={ - <> - <span class="">Edit</span> {getFilename(state().input["filePath"] as string)} - </> - } - > - <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div> - </CollapsiblePart> - )} - </Match> - </Switch> - ) -} - -function WriteToolPart(props: { part: ToolPart }) { - const sync = useSync() - return ( - <Switch> - <Match when={props.part.state.status === "pending"}> - <Part>Preparing write...</Part> - </Match> - <Match when={props.part.state.status === "completed" && props.part.state}> - {(state) => ( - <CollapsiblePart - title={ - <> - <span class="">Write</span> {getFilename(state().input["filePath"] as string)} - </> - } - > - <div class="p-2 bg-background-panel rounded-md border border-border-subtle"></div> - </CollapsiblePart> - )} - </Match> - <Match when={props.part.state.status === "error" && props.part.state}> - {(state) => ( - <div> - <Part> - <span class="">Write</span> {getFilename(state().input["filePath"] as string)} - </Part> - <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div> - </div> - )} - </Match> - </Switch> - ) -} - -function BashToolPart(props: { part: ToolPart }) { - const sync = useSync() - return ( - <Switch> - <Match when={props.part.state.status === "pending"}> - <Part>Writing shell command...</Part> - </Match> - <Match when={props.part.state.status === "completed" && props.part.state}> - {(state) => ( - <CollapsiblePart - defaultOpen - title={ - <> - <span class="">Run command:</span> {state().input["command"]} - </> - } - > - <Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} /> - </CollapsiblePart> - )} - </Match> - <Match when={props.part.state.status === "error" && props.part.state}> - {(state) => ( - <CollapsiblePart - title={ - <> - <span class="">Shell</span> {state().input["command"]} - </> - } - > - <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div> - </CollapsiblePart> - )} - </Match> - </Switch> - ) -} - -function ToolPart(props: { part: ToolPart }) { - // read - // edit - // write - // bash - // ls - // glob - // grep - // todowrite - // todoread - // webfetch - // websearch - // patch - // task - return ( - <div class="min-w-0 flex-auto text-12-medium"> - <Switch - fallback={ - <span> - {props.part.type}:{props.part.tool} - </span> - } - > - <Match when={props.part.tool === "read"}> - <ReadToolPart part={props.part} /> - </Match> - <Match when={props.part.tool === "edit"}> - <EditToolPart part={props.part} /> - </Match> - <Match when={props.part.tool === "write"}> - <WriteToolPart part={props.part} /> - </Match> - <Match when={props.part.tool === "bash"}> - <BashToolPart part={props.part} /> - </Match> - </Switch> - </div> - ) -} - -export default function SessionTimeline(props: { session: string; class?: string }) { - const sync = useSync() - const [scrollElement, setScrollElement] = createSignal<HTMLElement | undefined>(undefined) - const [root, setRoot] = createSignal<HTMLDivElement | undefined>(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 ( - <div - ref={setRoot} - classList={{ - "select-text flex flex-col text-text-weak": true, - [props.class ?? ""]: !!props.class, - }} - > - <div class="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 items-start self-stretch"> - <For each={messagesWithValidParts()}> - {(message) => ( - <div - classList={{ - "flex flex-col gap-1 justify-center items-start self-stretch": true, - "mt-6": hasTextPart(message), - }} - > - <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> - <Show when={false}> - <Collapsible defaultOpen={false}> - <Collapsible.Trigger> - <div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted"> - <Icon name="file-code" /> - <span>Raw Session Data</span> - <Collapsible.Arrow class="text-text-muted" /> - </div> - </Collapsible.Trigger> - <Collapsible.Content class="mt-5"> - <ul role="list" class="space-y-2"> - <li> - <Collapsible> - <Collapsible.Trigger> - <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1"> - <Icon name="file-code" /> - <span>session</span> - <Collapsible.Arrow class="text-text-muted" /> - </div> - </Collapsible.Trigger> - <Collapsible.Content> - <Code path="session.json" code={JSON.stringify(session(), null, 2)} /> - </Collapsible.Content> - </Collapsible> - </li> - <For each={messages()}> - {(message) => ( - <> - <li> - <Collapsible> - <Collapsible.Trigger> - <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1"> - <Icon name="file-code" /> - <span>{message.role === "user" ? "user" : "assistant"}</span> - <Collapsible.Arrow class="text-text-muted" /> - </div> - </Collapsible.Trigger> - <Collapsible.Content> - <Code path={message.id + ".json"} code={JSON.stringify(message, null, 2)} /> - </Collapsible.Content> - </Collapsible> - </li> - <For each={sync.data.part[message.id]}> - {(part) => ( - <li> - <Collapsible> - <Collapsible.Trigger> - <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1"> - <Icon name="file-code" /> - <span>{part.type}</span> - <Collapsible.Arrow class="text-text-muted" /> - </div> - </Collapsible.Trigger> - <Collapsible.Content> - <Code path={message.id + "." + part.id + ".json"} code={JSON.stringify(part, null, 2)} /> - </Collapsible.Content> - </Collapsible> - </li> - )} - </For> - </> - )} - </For> - </ul> - </Collapsible.Content> - </Collapsible> - </Show> - </div> - ) -} 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() { <Show when={local.session.active()} fallback={ - <div class="flex flex-col pb-36 justify-end items-start gap-4 flex-[1_0_0] self-stretch"> + <div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch"> <div class="text-20-medium text-text-weaker">New session</div> <div class="flex justify-center items-center gap-3"> <Icon name="folder" size="small" /> @@ -660,7 +661,7 @@ export default function Page() { class="flex flex-col items-start self-stretch gap-8 min-h-screen" > {/* Title */} - <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger"> + <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10"> <h1 class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0"> {title() ?? prompt()} </h1> diff --git a/packages/ui/package.json b/packages/ui/package.json index 8fd6bff6b..520baf6e7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,6 +11,7 @@ "./fonts/*": "./src/assets/fonts/*" }, "scripts": { + "typecheck": "tsgo --noEmit", "dev": "vite", "generate:tailwind": "bun run script/tailwind.ts" }, @@ -24,6 +25,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/sdk": "workspace:*", "@pierre/precision-diffs": "catalog:", "@solidjs/meta": "catalog:", "fuzzysort": "catalog:", diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index 3d8c8ebea..4b2c14d4d 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -11,7 +11,7 @@ [data-slot="collapsible-trigger"] { width: 100%; display: flex; - height: 40px; + height: 32px; padding: 6px 8px 6px 12px; align-items: center; align-self: stretch; diff --git a/packages/ui/src/components/diff-changes.css b/packages/ui/src/components/diff-changes.css new file mode 100644 index 000000000..afca51474 --- /dev/null +++ b/packages/ui/src/components/diff-changes.css @@ -0,0 +1,28 @@ +[data-component="diff-changes"] { + display: flex; + gap: 8px; + justify-content: flex-end; + align-items: center; + + [data-slot="additions"] { + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + text-align: right; + color: var(--text-diff-add-base); + } + + [data-slot="deletions"] { + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + text-align: right; + color: var(--text-diff-delete-base); + } +} diff --git a/packages/ui/src/components/diff-changes.tsx b/packages/ui/src/components/diff-changes.tsx new file mode 100644 index 000000000..7661a9741 --- /dev/null +++ b/packages/ui/src/components/diff-changes.tsx @@ -0,0 +1,24 @@ +import type { 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() ?? 0) + (deletions() ?? 0)) + return ( + <Show when={total() > 0}> + <div data-component="diff-changes"> + <span data-slot="additions">{`+${additions()}`}</span> + <span data-slot="deletions">{`-${deletions()}`}</span> + </div> + </Show> + ) +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 16cbb7d95..4b60ddabc 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -4,12 +4,16 @@ export * from "./checkbox" export * from "./collapsible" export * from "./dialog" export * from "./diff" +export * from "./diff-changes" export * from "./icon" export * from "./icon-button" export * from "./input" export * from "./fonts" export * from "./list" +export * from "./message-part" export * from "./select" export * from "./select-dialog" export * from "./tabs" +export * from "./tool-display" +export * from "./tool-registry" export * from "./tooltip" diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css new file mode 100644 index 000000000..8931d3bc6 --- /dev/null +++ b/packages/ui/src/components/message-part.css @@ -0,0 +1,22 @@ +[data-component="assistant-message"] { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; +} + +[data-component="user-message"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-base); + display: -webkit-box; + line-clamp: 3; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx new file mode 100644 index 000000000..eddd796e7 --- /dev/null +++ b/packages/ui/src/components/message-part.tsx @@ -0,0 +1,87 @@ +import { Component, createMemo, For, Match, Show, Switch } from "solid-js" +import { Dynamic } from "solid-js/web" +import { + AssistantMessage, + Message as MessageType, + Part as PartType, + TextPart, + ToolPart, + UserMessage, +} from "@opencode-ai/sdk" + +export interface MessageProps { + message: MessageType + parts: PartType[] +} + +export interface MessagePartProps { + part: PartType + message: MessageType + hideDetails?: boolean +} + +export type PartComponent = Component<MessagePartProps> + +const PART_MAPPING: Record<string, PartComponent | undefined> = {} + +export function registerPartComponent(type: string, component: PartComponent) { + PART_MAPPING[type] = component +} + +export function Message(props: MessageProps) { + return ( + <Switch> + <Match when={props.message.role === "user" && props.message}> + {(userMessage) => ( + <UserMessageDisplay message={userMessage() as UserMessage} parts={props.parts} /> + )} + </Match> + <Match when={props.message.role === "assistant" && props.message}> + {(assistantMessage) => ( + <AssistantMessageDisplay + message={assistantMessage() as AssistantMessage} + parts={props.parts} + /> + )} + </Match> + </Switch> + ) +} + +export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) { + const filteredParts = createMemo(() => { + return props.parts?.filter((x) => { + if (x.type === "reasoning") return false + return x.type !== "tool" || (x as ToolPart).tool !== "todoread" + }) + }) + return ( + <div data-component="assistant-message"> + <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For> + </div> + ) +} + +export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { + const text = createMemo(() => + props.parts + ?.filter((p) => p.type === "text" && !(p as TextPart).synthetic) + ?.map((p) => (p as TextPart).text) + ?.join(""), + ) + return <div data-component="user-message">{text()}</div> +} + +export function Part(props: MessagePartProps) { + const component = createMemo(() => PART_MAPPING[props.part.type]) + return ( + <Show when={component()}> + <Dynamic + component={component()} + part={props.part} + message={props.message} + hideDetails={props.hideDetails} + /> + </Show> + ) +} diff --git a/packages/ui/src/components/tool-display.css b/packages/ui/src/components/tool-display.css new file mode 100644 index 000000000..f3d9f865f --- /dev/null +++ b/packages/ui/src/components/tool-display.css @@ -0,0 +1,76 @@ +[data-component="tool-trigger"] { + width: 100%; + display: flex; + align-items: center; + align-self: stretch; + gap: 20px; + justify-content: space-between; + + [data-slot="tool-trigger-content"] { + width: 100%; + display: flex; + align-items: center; + align-self: stretch; + gap: 20px; + } + + [data-slot="tool-icon"] { + flex-shrink: 0; + } + + [data-slot="tool-info"] { + flex-grow: 1; + min-width: 0; + } + + [data-slot="tool-info-structured"] { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; + } + + [data-slot="tool-info-main"] { + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + [data-slot="tool-title"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-base); + + &.capitalize { + text-transform: capitalize; + } + } + + [data-slot="tool-subtitle"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-weak); + } + + [data-slot="tool-arg"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-weak); + } +} diff --git a/packages/ui/src/components/tool-display.tsx b/packages/ui/src/components/tool-display.tsx new file mode 100644 index 000000000..43574fbb7 --- /dev/null +++ b/packages/ui/src/components/tool-display.tsx @@ -0,0 +1,95 @@ +import { children, For, Match, Show, Switch, type JSX } from "solid-js" +import { Collapsible } from "./collapsible" +import { Icon, IconProps } from "./icon" + +export 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) +} + +export interface BasicToolProps { + icon: IconProps["name"] + trigger: TriggerTitle | JSX.Element + children?: JSX.Element + hideDetails?: boolean +} + +export function BasicTool(props: BasicToolProps) { + const resolved = children(() => props.children) + return ( + <Collapsible> + <Collapsible.Trigger> + <div data-component="tool-trigger"> + <div data-slot="tool-trigger-content"> + <Icon name={props.icon} size="small" data-slot="tool-icon" /> + <div data-slot="tool-info"> + <Switch> + <Match when={isTriggerTitle(props.trigger) && props.trigger}> + {(trigger) => ( + <div data-slot="tool-info-structured"> + <div data-slot="tool-info-main"> + <span + data-slot="tool-title" + classList={{ + [trigger().titleClass ?? ""]: !!trigger().titleClass, + }} + > + {trigger().title} + </span> + <Show when={trigger().subtitle}> + <span + data-slot="tool-subtitle" + classList={{ + [trigger().subtitleClass ?? ""]: !!trigger().subtitleClass, + }} + > + {trigger().subtitle} + </span> + </Show> + <Show when={trigger().args?.length}> + <For each={trigger().args}> + {(arg) => ( + <span + data-slot="tool-arg" + classList={{ + [trigger().argsClass ?? ""]: !!trigger().argsClass, + }} + > + {arg} + </span> + )} + </For> + </Show> + </div> + <Show when={trigger().action}>{trigger().action}</Show> + </div> + )} + </Match> + <Match when={true}>{props.trigger as JSX.Element}</Match> + </Switch> + </div> + </div> + <Show when={resolved() && !props.hideDetails}> + <Collapsible.Arrow /> + </Show> + </div> + </Collapsible.Trigger> + <Show when={resolved() && !props.hideDetails}> + <Collapsible.Content>{resolved()}</Collapsible.Content> + </Show> + </Collapsible> + ) +} + +export function GenericTool(props: { tool: string; hideDetails?: boolean }) { + return <BasicTool icon="mcp" trigger={{ title: props.tool }} hideDetails={props.hideDetails} /> +} diff --git a/packages/ui/src/components/tool-registry.tsx b/packages/ui/src/components/tool-registry.tsx new file mode 100644 index 000000000..8ee7d8293 --- /dev/null +++ b/packages/ui/src/components/tool-registry.tsx @@ -0,0 +1,33 @@ +import { Component } from "solid-js" + +export interface ToolProps { + input: Record<string, any> + metadata: Record<string, any> + tool: string + output?: string + hideDetails?: boolean +} + +export type ToolComponent = Component<ToolProps> + +const state: Record< + string, + { + name: string + render?: ToolComponent + } +> = {} + +export function registerTool(input: { name: string; render?: ToolComponent }) { + state[input.name] = input + return input +} + +export function getTool(name: string) { + return state[name]?.render +} + +export const ToolRegistry = { + register: registerTool, + render: getTool, +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 94fa894d4..3ebe6e9ea 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -9,15 +9,18 @@ @import "../components/button.css" layer(components); @import "../components/checkbox.css" layer(components); @import "../components/diff.css" layer(components); +@import "../components/diff-changes.css" layer(components); @import "../components/collapsible.css" layer(components); @import "../components/dialog.css" layer(components); @import "../components/icon.css" layer(components); @import "../components/icon-button.css" layer(components); @import "../components/input.css" layer(components); @import "../components/list.css" layer(components); +@import "../components/message-part.css" layer(components); @import "../components/select.css" layer(components); @import "../components/select-dialog.css" layer(components); @import "../components/tabs.css" layer(components); +@import "../components/tool-display.css" layer(components); @import "../components/tooltip.css" layer(components); @import "./utilities.css" layer(utilities); |
