wip: desktop work

This commit is contained in:
Adam
2025-10-30 07:26:06 -05:00
parent 3541fdcb20
commit 30f4c2cf4c
17 changed files with 427 additions and 789 deletions

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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>
)
}

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View File

@@ -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} />
}

View File

@@ -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,
}