diff options
Diffstat (limited to 'packages/desktop/src/components/message.tsx')
| -rw-r--r-- | packages/desktop/src/components/message.tsx | 459 |
1 files changed, 459 insertions, 0 deletions
diff --git a/packages/desktop/src/components/message.tsx b/packages/desktop/src/components/message.tsx new file mode 100644 index 000000000..589ca3118 --- /dev/null +++ b/packages/desktop/src/components/message.tsx @@ -0,0 +1,459 @@ +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 { Dynamic } from "solid-js/web" +import { Markdown } from "./markdown" +import { Checkbox, Collapsible, Diff, Icon, IconProps } 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> + ) +} + +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" + }) + }) + 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> + ) +} + +function TextPart(props: { part: TextPart; message: Message }) { + return ( + <Show when={props.part.text.trim()}> + <Markdown text={props.part.text.trim()} /> + </Show> + ) +} + +function ToolPart(props: { part: ToolPart; message: Message; hideDetails?: boolean }) { + 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 : {} + + return ( + <Dynamic + component={render} + input={input} + tool={props.part.tool} + metadata={metadata} + output={props.part.state.status === "completed" ? props.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>({ + name: "read", + render(props) { + return ( + <BasicTool + icon="glasses" + trigger={{ title: "Read", subtitle: props.input.filePath ? getFilename(props.input.filePath) : "" }} + /> + ) + }, +}) + +ToolRegistry.register<typeof ListTool>({ + name: "list", + render(props) { + return ( + <BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(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: "Glob", + subtitle: getDirectory(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: "Grep", + subtitle: getDirectory(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: "Webfetch", + 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`, + titleClass: "capitalize", + 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-2"> + <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"> + <Show when={props.metadata.filediff}> + <DiffChanges diff={props.metadata.filediff} /> + </Show> + </div> + </div> + } + > + <Show when={props.metadata.filediff}> + <div class="border-t border-border-weaker-base"> + <Diff + before={{ name: getFilename(props.metadata.filediff.path), contents: props.metadata.filediff.before }} + after={{ name: getFilename(props.metadata.filediff.path), contents: props.metadata.filediff.after }} + /> + </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-2"> + <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> + ) + }, +}) + +ToolRegistry.register<typeof TodoWriteTool>({ + name: "todowrite", + render(props) { + return ( + <BasicTool + icon="checklist" + trigger={{ + title: "To-dos", + subtitle: `${props.input.todos?.filter((t) => 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) => ( + <Checkbox readOnly checked={todo.status === "completed"}> + <div classList={{ "line-through text-text-weaker": todo.status === "completed" }}>{todo.content}</div> + </Checkbox> + )} + </For> + </div> + </Show> + </BasicTool> + ) + }, +}) |
