diff options
| author | Adam <[email protected]> | 2025-10-27 15:35:47 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-10-27 15:37:07 -0500 |
| commit | fc115ea367dd034c7b989819d4f547c5d7519253 (patch) | |
| tree | 653401ef94212e161334108449a11f60ba54dc86 /packages/desktop | |
| parent | d03b79e61eef0be1cca669e5e6a13df78cc4be85 (diff) | |
| download | opencode-fc115ea367dd034c7b989819d4f547c5d7519253.tar.gz opencode-fc115ea367dd034c7b989819d4f547c5d7519253.zip | |
wip: desktop work
Diffstat (limited to 'packages/desktop')
| -rw-r--r-- | packages/desktop/package.json | 3 | ||||
| -rw-r--r-- | packages/desktop/src/components/assistant-message.tsx | 362 | ||||
| -rw-r--r-- | packages/desktop/src/components/diff-changes.tsx | 20 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 8 | ||||
| -rw-r--r-- | packages/desktop/src/pages/index.tsx | 180 |
5 files changed, 517 insertions, 56 deletions
diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 135ee9bb1..c4af384f4 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -12,12 +12,13 @@ }, "license": "MIT", "devDependencies": { + "opencode": "workspace:*", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/luxon": "3.7.1", "@types/node": "catalog:", - "typescript": "catalog:", "@typescript/native-preview": "catalog:", + "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-solid": "catalog:" diff --git a/packages/desktop/src/components/assistant-message.tsx b/packages/desktop/src/components/assistant-message.tsx new file mode 100644 index 000000000..2e3d659aa --- /dev/null +++ b/packages/desktop/src/components/assistant-message.tsx @@ -0,0 +1,362 @@ +import type { Part, AssistantMessage, ReasoningPart, TextPart, ToolPart } from "@opencode-ai/sdk" +import type { Tool } from "opencode/tool/tool" +import type { ReadTool } from "opencode/tool/read" +import { children, Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js" +import { Dynamic } from "solid-js/web" +import { Markdown } from "./markdown" +import { Collapsible, Icon, IconProps } from "@opencode-ai/ui" +import { getDirectory, getFilename } from "@/utils" +import { ListTool } from "opencode/tool/ls" +import { GlobTool } from "opencode/tool/glob" +import { GrepTool } from "opencode/tool/grep" +import { WebFetchTool } from "opencode/tool/webfetch" +import { TaskTool } from "opencode/tool/task" +import { BashTool } from "opencode/tool/bash" +import { EditTool } from "opencode/tool/edit" +import { DiffChanges } from "./diff-changes" +import { WriteTool } from "opencode/tool/write" + +export function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) { + return ( + <div class="w-full flex flex-col items-start gap-4"> + <For each={props.parts}> + {(part) => { + const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) + return ( + <Show when={component()}> + <Dynamic component={component()} part={part as any} message={props.message} /> + </Show> + ) + }} + </For> + </div> + ) +} + +const PART_MAPPING = { + text: TextPart, + tool: ToolPart, + reasoning: ReasoningPart, +} + +function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }) { + return null + // return ( + // <Show when={props.part.text.trim()}> + // <div>{props.part.text}</div> + // </Show> + // ) +} + +function TextPart(props: { part: TextPart; message: AssistantMessage }) { + return ( + <Show when={props.part.text.trim()}> + <Markdown text={props.part.text.trim()} /> + </Show> + ) +} + +function ToolPart(props: { part: ToolPart; message: AssistantMessage }) { + // const sync = useSync() + + 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 permissions = sync.data.permission[props.message.sessionID] ?? [] + // const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID) + // const permission = permissions[permissionIndex] + + return ( + <> + <Dynamic + component={render} + input={input} + tool={props.part.tool} + metadata={metadata} + // permission={permission?.metadata ?? {}} + output={props.part.state.status === "completed" ? props.part.state.output : undefined} + /> + {/* <Show when={props.part.state.status === "error"}>{props.part.state.error.replace("Error: ", "")}</Show> */} + </> + ) + }) + + return <Show when={component()}>{component()}</Show> +} + +type TriggerTitle = { + title: string + subtitle?: string + args?: 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 }) { + 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" /> + <Switch> + <Match when={isTriggerTitle(props.trigger)}> + <div class="w-full flex items-center gap-2 justify-between"> + <div class="flex items-center gap-2"> + <span class="text-12-medium text-text-base capitalize"> + {(props.trigger as TriggerTitle).title} + </span> + <Show when={(props.trigger as TriggerTitle).subtitle}> + <span class="text-12-medium text-text-weak">{(props.trigger as TriggerTitle).subtitle}</span> + </Show> + <Show when={(props.trigger as TriggerTitle).args?.length}> + <For each={(props.trigger as TriggerTitle).args}> + {(arg) => <span class="text-12-regular text-text-weaker">{arg}</span>} + </For> + </Show> + </div> + <Show when={(props.trigger as TriggerTitle).action}>{(props.trigger as TriggerTitle).action}</Show> + </div> + </Match> + <Match when={true}>{props.trigger as JSX.Element}</Match> + </Switch> + </div> + <Show when={resolved()}> + <Collapsible.Arrow /> + </Show> + </div> + </Collapsible.Trigger> + <Show when={props.children}> + <Collapsible.Content>{props.children}</Collapsible.Content> + </Show> + </Collapsible> + ) +} + +function GenericTool(props: ToolProps<any>) { + return <BasicTool icon="mcp" trigger={{ title: props.tool }} /> +} + +type ToolProps<T extends Tool.Info> = { + input: Partial<Tool.InferParameters<T>> + metadata: Partial<Tool.InferMetadata<T>> + // permission: Record<string, any> + tool: string + output?: string +} + +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>({ + name: "read", + render(props) { + return ( + <BasicTool + icon="glasses" + trigger={{ title: props.tool, subtitle: props.input.filePath ? getFilename(props.input.filePath) : "" }} + /> + ) + }, +}) + +ToolRegistry.register<typeof ListTool>({ + name: "list", + render(props) { + return ( + <BasicTool icon="bullet-list" trigger={{ title: props.tool, subtitle: props.input.path || "/" }}> + <Show when={false && props.output}> + <div class="whitespace-pre">{props.output}</div> + </Show> + </BasicTool> + ) + }, +}) + +ToolRegistry.register<typeof GlobTool>({ + name: "glob", + render(props) { + return ( + <BasicTool + icon="magnifying-glass-menu" + trigger={{ + title: props.tool, + subtitle: props.input.path || "/", + args: props.input.pattern ? ["pattern=" + props.input.pattern] : [], + }} + > + <Show when={false && props.output}> + <div class="whitespace-pre">{props.output}</div> + </Show> + </BasicTool> + ) + }, +}) + +ToolRegistry.register<typeof GrepTool>({ + name: "grep", + render(props) { + const args = [] + if (props.input.pattern) args.push("pattern=" + props.input.pattern) + if (props.input.include) args.push("include=" + props.input.include) + return ( + <BasicTool + icon="magnifying-glass-menu" + trigger={{ + title: props.tool, + subtitle: props.input.path || "/", + args, + }} + > + <Show when={false && props.output}> + <div class="whitespace-pre">{props.output}</div> + </Show> + </BasicTool> + ) + }, +}) + +ToolRegistry.register<typeof WebFetchTool>({ + name: "webfetch", + render(props) { + return ( + <BasicTool + icon="window-cursor" + trigger={{ + title: props.tool, + subtitle: props.input.url || "", + args: props.input.format ? ["format=" + props.input.format] : [], + action: ( + <div class="size-6 flex items-center justify-center"> + <Icon name="square-arrow-top-right" size="small" /> + </div> + ), + }} + > + <Show when={false && props.output}> + <div class="whitespace-pre">{props.output}</div> + </Show> + </BasicTool> + ) + }, +}) + +ToolRegistry.register<typeof TaskTool>({ + name: "task", + render(props) { + return ( + <BasicTool + icon="task" + trigger={{ + title: `${props.input.subagent_type || props.tool} Agent`, + subtitle: props.input.description, + }} + > + <Show when={false && props.output}> + <div class="whitespace-pre">{props.output}</div> + </Show> + </BasicTool> + ) + }, +}) + +ToolRegistry.register<typeof BashTool>({ + name: "bash", + render(props) { + return ( + <BasicTool + icon="console" + trigger={{ + title: "Shell", + subtitle: "Ran " + props.input.command, + }} + > + <Show when={false && props.output}> + <div class="whitespace-pre">{props.output}</div> + </Show> + </BasicTool> + ) + }, +}) + +ToolRegistry.register<typeof EditTool>({ + name: "edit", + render(props) { + return ( + <BasicTool + icon="code-lines" + trigger={ + <div class="flex items-center justify-between w-full"> + <div class="flex items-center gap-5"> + <div class="text-12-medium text-text-base capitalize">Edit</div> + <div class="flex"> + <Show when={props.input.filePath?.includes("/")}> + <span class="text-text-weak">{getDirectory(props.input.filePath!)}/</span> + </Show> + <span class="text-text-strong">{getFilename(props.input.filePath ?? "")}</span> + </div> + </div> + <div class="flex gap-4 items-center justify-end">{/* <DiffChanges diff={diff} /> */}</div> + </div> + } + > + <Show when={false && props.output}> + <div class="whitespace-pre">{props.output}</div> + </Show> + </BasicTool> + ) + }, +}) + +ToolRegistry.register<typeof WriteTool>({ + name: "write", + render(props) { + return ( + <BasicTool + icon="code-lines" + trigger={ + <div class="flex items-center justify-between w-full"> + <div class="flex items-center gap-5"> + <div class="text-12-medium text-text-base capitalize">Write</div> + <div class="flex"> + <Show when={props.input.filePath?.includes("/")}> + <span class="text-text-weak">{getDirectory(props.input.filePath!)}/</span> + </Show> + <span class="text-text-strong">{getFilename(props.input.filePath ?? "")}</span> + </div> + </div> + <div class="flex gap-4 items-center justify-end">{/* <DiffChanges diff={diff} /> */}</div> + </div> + } + > + <Show when={false && props.output}> + <div class="whitespace-pre">{props.output}</div> + </Show> + </BasicTool> + ) + }, +}) diff --git a/packages/desktop/src/components/diff-changes.tsx b/packages/desktop/src/components/diff-changes.tsx new file mode 100644 index 000000000..3b633f70f --- /dev/null +++ b/packages/desktop/src/components/diff-changes.tsx @@ -0,0 +1,20 @@ +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/context/local.tsx b/packages/desktop/src/context/local.tsx index 6ed8ec17b..978dbfbc6 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -460,13 +460,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage) }) - const activeAssistantMessages = createMemo(() => { - if (!store.active || !activeMessage()) return [] - return sync.data.message[store.active]?.filter( - (m) => m.role === "assistant" && m.parentID == activeMessage()?.id, - ) - }) - const model = createMemo(() => { if (!last()) return const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] @@ -504,7 +497,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return { active, activeMessage, - activeAssistantMessages, lastUserMessage, cost, last, diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 6702284b2..15da87bd6 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -22,6 +22,10 @@ import { Code } from "@/components/code" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { Diff } from "@/components/diff" +import { ProgressCircle } from "@/components/progress-circle" +import { AssistantMessage } from "@/components/assistant-message" +import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" +import { DiffChanges } from "@/components/diff-changes" export default function Page() { const local = useLocal() @@ -92,7 +96,7 @@ export default function Page() { } } - if (event.key.length === 1 && event.key !== "Unidentified") { + if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } } @@ -392,9 +396,6 @@ export default function Page() { {(session) => { const diffs = createMemo(() => session.summary?.diffs ?? []) const filesChanged = createMemo(() => diffs().length) - const additions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.additions ?? 0), 0)) - const deletions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.deletions ?? 0), 0)) - return ( <Tooltip placement="right" value={session.title}> <div> @@ -408,12 +409,7 @@ export default function Page() { </div> <div class="flex justify-between items-center self-stretch"> <span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span> - <Show when={additions() || deletions()}> - <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> + <DiffChanges diff={diffs()} /> </div> </div> </Tooltip> @@ -434,13 +430,12 @@ export default function Page() { <Tabs onChange={handleTabChange}> <div class="sticky top-0 shrink-0 flex"> <Tabs.List> - <Tabs.Trigger value="chat" class="flex gap-x-1.5 items-center"> + <Tabs.Trigger value="chat" class="flex gap-x-4 items-center"> <div>Chat</div> - <Show when={local.session.active()}> - <div class="flex flex-col h-4 px-2 -mr-2 justify-center items-center rounded-full bg-surface-base text-12-medium text-text-strong"> - {local.session.context()}% - </div> - </Show> + <Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5"> + <ProgressCircle percentage={local.session.context() ?? 0} /> + <div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div> + </Tooltip> </Tabs.Trigger> {/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */} <SortableProvider ids={local.file.opened().map((file) => file.path)}> @@ -548,33 +543,114 @@ export default function Page() { <Show when={local.session.userMessages().length > 1}> <ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1"> <For each={local.session.userMessages()}> - {(message) => ( - <li - class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default" - onClick={() => local.session.setActiveMessage(message.id)} - > - <div class="w-[18px] shrink-0"> - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none"> - <g> - <rect x="0" width="2" height="12" rx="1" fill="#CFCECD" /> - <rect x="4" width="2" height="12" rx="1" fill="#CFCECD" /> - <rect x="8" width="2" height="12" rx="1" fill="#CFCECD" /> - <rect x="12" width="2" height="12" rx="1" fill="#CFCECD" /> - <rect x="16" width="2" height="12" rx="1" fill="#CFCECD" /> - </g> - </svg> - </div> - <div - data-active={local.session.activeMessage()?.id === message.id} - classList={{ - "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true, - "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true, - }} + {(message) => { + const countLines = (text: string) => { + if (!text) return 0 + return text.split("\n").length + } + + const additions = createMemo( + () => + message.summary?.diffs.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) ?? 0, + ) + + const deletions = createMemo( + () => + message.summary?.diffs.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) ?? 0, + ) + + const totalBeforeLines = createMemo( + () => + message.summary?.diffs.reduce((acc, diff) => acc + countLines(diff.before), 0) ?? + 0, + ) + + const blockCounts = createMemo(() => { + const TOTAL_BLOCKS = 5 + + const adds = additions() + const dels = deletions() + const unchanged = Math.max(0, totalBeforeLines() - dels) + + const totalActivity = unchanged + adds + dels + + if (totalActivity === 0) { + return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS } + } + + const percentAdded = adds / totalActivity + const percentDeleted = dels / totalActivity + const added_raw = percentAdded * TOTAL_BLOCKS + const deleted_raw = percentDeleted * TOTAL_BLOCKS + + let added = adds > 0 ? Math.ceil(added_raw) : 0 + let deleted = dels > 0 ? Math.ceil(deleted_raw) : 0 + + let total_allocated = added + deleted + if (total_allocated > TOTAL_BLOCKS) { + if (added_raw < deleted_raw) { + added = Math.floor(added_raw) + } else { + deleted = Math.floor(deleted_raw) + } + + total_allocated = added + deleted + if (total_allocated > TOTAL_BLOCKS) { + if (added_raw < deleted_raw) { + deleted = Math.floor(deleted_raw) + } else { + added = Math.floor(added_raw) + } + } + } + + const neutral = Math.max(0, TOTAL_BLOCKS - added - deleted) + + return { added, deleted, neutral } + }) + + const ADD_COLOR = "var(--icon-diff-add-base)" + const DELETE_COLOR = "var(--icon-diff-delete-base)" + const NEUTRAL_COLOR = "var(--icon-weak-base)" + + const visibleBlocks = createMemo(() => { + const counts = blockCounts() + const blocks = [ + ...Array(counts.added).fill(ADD_COLOR), + ...Array(counts.deleted).fill(DELETE_COLOR), + ...Array(counts.neutral).fill(NEUTRAL_COLOR), + ] + return blocks.slice(0, 5) + }) + + return ( + <li + class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default" + onClick={() => local.session.setActiveMessage(message.id)} > - {message.summary?.title ?? local.session.getMessageText(message)} - </div> - </li> - )} + <div class="w-[18px] shrink-0"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none"> + <g> + <For each={visibleBlocks()}> + {(color, i) => ( + <rect x={i() * 4} width="2" height="12" rx="1" fill={color} /> + )} + </For> + </g> + </svg> + </div> + <div + data-active={local.session.activeMessage()?.id === message.id} + classList={{ + "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true, + "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true, + }} + > + {message.summary?.title ?? local.session.getMessageText(message)} + </div> + </li> + ) + }} </For> </ul> </Show> @@ -585,6 +661,11 @@ export default function Page() { const title = createMemo(() => message.summary?.title) const prompt = createMemo(() => local.session.getMessageText(message)) const summary = createMemo(() => message.summary?.body) + const assistantMessages = createMemo(() => { + return sync.data.message[activeSession().id]?.filter( + (m) => m.role === "assistant" && m.parentID == message.id, + ) as AssistantMessageType[] + }) return ( <div @@ -633,10 +714,7 @@ export default function Page() { </div> </div> <div class="flex gap-4 items-center justify-end"> - <div class="flex gap-2 justify-end items-center"> - <span class="text-12-mono text-right text-text-diff-add-base">{`+${diff.additions}`}</span> - <span class="text-12-mono text-right text-text-diff-delete-base">{`-${diff.deletions}`}</span> - </div> + <DiffChanges diff={diff} /> <Icon name="chevron-grabber-vertical" size="small" /> </div> </div> @@ -661,10 +739,18 @@ export default function Page() { </Show> </div> {/* Response */} - <div data-todo="Response (Timeline)"> + <div data-todo="Response" class="w-full"> <div class="flex flex-col items-start gap-1 self-stretch"> <h2 class="text-12-medium text-text-weak">Response</h2> </div> + <div class="w-full flex flex-col items-start self-stretch gap-8"> + <For each={assistantMessages()}> + {(assistantMessage) => { + const parts = createMemo(() => sync.data.part[assistantMessage.id]) + return <AssistantMessage message={assistantMessage} parts={parts()} /> + }} + </For> + </div> </div> </div> ) |
