diff options
| author | David Hill <[email protected]> | 2025-10-30 22:53:00 +0000 |
|---|---|---|
| committer | David Hill <[email protected]> | 2025-10-30 22:53:00 +0000 |
| commit | 2f9f189f390401b3314c74bd9f83e64b2420cd65 (patch) | |
| tree | b275ca785719a54115752a79d4b66984ba125670 /packages/desktop/src | |
| parent | f3c70f4ea8818bde5b1228482f72af9b10c71796 (diff) | |
| parent | a3ba740de41eb1e4825a99dc8f519e1225357e55 (diff) | |
| download | opencode-2f9f189f390401b3314c74bd9f83e64b2420cd65.tar.gz opencode-2f9f189f390401b3314c74bd9f83e64b2420cd65.zip | |
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/desktop/src')
| -rw-r--r-- | packages/desktop/src/components/code.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/components/diff-changes.tsx | 20 | ||||
| -rw-r--r-- | packages/desktop/src/components/markdown.tsx | 23 | ||||
| -rw-r--r-- | packages/desktop/src/components/message.tsx | 459 | ||||
| -rw-r--r-- | packages/desktop/src/components/progress-circle.tsx | 48 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 15 | ||||
| -rw-r--r-- | packages/desktop/src/components/session-timeline.tsx | 536 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 89 | ||||
| -rw-r--r-- | packages/desktop/src/context/marked.tsx | 30 | ||||
| -rw-r--r-- | packages/desktop/src/context/shiki.tsx | 573 | ||||
| -rw-r--r-- | packages/desktop/src/context/sync.tsx | 35 | ||||
| -rw-r--r-- | packages/desktop/src/index.tsx | 4 | ||||
| -rw-r--r-- | packages/desktop/src/pages/index.tsx | 140 |
13 files changed, 146 insertions, 1828 deletions
diff --git a/packages/desktop/src/components/code.tsx b/packages/desktop/src/components/code.tsx index 11518e73a..c214fd5e6 100644 --- a/packages/desktop/src/components/code.tsx +++ b/packages/desktop/src/components/code.tsx @@ -2,7 +2,7 @@ import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "s import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js" import { useLocal, type TextSelection } from "@/context/local" import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils" -import { useShiki } from "@/context/shiki" +import { useShiki } from "@opencode-ai/ui" type DefinedSelection = Exclude<TextSelection, undefined> 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/markdown.tsx b/packages/desktop/src/components/markdown.tsx deleted file mode 100644 index e0f185f5f..000000000 --- a/packages/desktop/src/components/markdown.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useMarked } from "@/context/marked" -import { createResource } from "solid-js" - -function strip(text: string): string { - const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/ - const match = text.match(wrappedRe) - return match ? match[2] : text -} -export function Markdown(props: { text: string; class?: string }) { - const marked = useMarked() - const [html] = createResource( - () => strip(props.text), - async (markdown) => { - return marked.parse(markdown) - }, - ) - return ( - <div - class={`min-w-0 max-w-full overflow-auto no-scrollbar text-14-regular text-text-base ${props.class ?? ""}`} - innerHTML={html()} - /> - ) -} diff --git a/packages/desktop/src/components/message.tsx b/packages/desktop/src/components/message.tsx deleted file mode 100644 index 589ca3118..000000000 --- a/packages/desktop/src/components/message.tsx +++ /dev/null @@ -1,459 +0,0 @@ -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> - ) - }, -}) diff --git a/packages/desktop/src/components/progress-circle.tsx b/packages/desktop/src/components/progress-circle.tsx deleted file mode 100644 index d56197ed3..000000000 --- a/packages/desktop/src/components/progress-circle.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Component, createMemo } from "solid-js" - -interface ProgressCircleProps { - percentage: number - size?: number - strokeWidth?: number -} - -export const ProgressCircle: Component<ProgressCircleProps> = (props) => { - // --- Set default values for props --- - const size = () => props.size || 16 - const strokeWidth = () => props.strokeWidth || 3 - - // --- Constants for SVG calculation --- - const viewBoxSize = 16 - const center = viewBoxSize / 2 - const radius = () => center - strokeWidth() / 2 - const circumference = createMemo(() => 2 * Math.PI * radius()) - - // --- Reactive Calculation for the progress offset --- - const offset = createMemo(() => { - const clampedPercentage = Math.max(0, Math.min(100, props.percentage || 0)) - const progress = clampedPercentage / 100 - return circumference() * (1 - progress) - }) - - return ( - <svg - width={size()} - height={size()} - viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`} - fill="none" - class="transform -rotate-90" - > - <circle cx={center} cy={center} r={radius()} class="stroke-border-weak-base" stroke-width={strokeWidth()} /> - <circle - cx={center} - cy={center} - r={radius()} - class="stroke-border-active" - stroke-width={strokeWidth()} - stroke-dasharray={circumference().toString()} - stroke-dashoffset={offset()} - style={{ transition: "stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1)" }} - /> - </svg> - ) -} diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index d6276c158..f1c19388e 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -71,7 +71,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } }) - const { flat, active, onInput, onKeyDown } = useFilteredList<string>({ + const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({ items: local.file.search, key: (x) => x, onSelect: (path) => { @@ -81,6 +81,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => { }, }) + createEffect(() => { + local.model.recent() + refetch() + }) + createEffect( on( () => store.contentParts, @@ -369,16 +374,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => { items={local.model.list()} current={local.model.current()} filterKeys={["provider.name", "name", "id"]} - groupBy={(x) => x.provider.name} + groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} sortGroupsBy={(a, b) => { const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] + if (a.category === "Recent" && b.category !== "Recent") return -1 + if (b.category === "Recent" && a.category !== "Recent") return 1 const aProvider = a.items[0].provider.id const bProvider = b.items[0].provider.id if (order.includes(aProvider) && !order.includes(bProvider)) return -1 if (!order.includes(aProvider) && order.includes(bProvider)) return 1 return order.indexOf(aProvider) - order.indexOf(bProvider) }} - onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)} + onSelect={(x) => + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true }) + } trigger={ <Button as="div" variant="ghost"> {local.model.current()?.name ?? "Select model"} 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/context/local.tsx b/packages/desktop/src/context/local.tsx index 9c4d70fc5..4607a184c 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -45,6 +45,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const sdk = useSDK() const sync = useSync() + function isModelValid(model: ModelKey) { + const provider = sync.data.provider.find((x) => x.id === model.providerID) + return !!provider?.models[model.modelID] + } + + function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) { + for (const modelFn of modelFns) { + const model = modelFn() + if (!model) continue + if (isModelValid(model)) return model + } + } + + // Automatically update model when agent changes + createEffect(() => { + const value = agent.current() + if (value.model) { + if (isModelValid(value.model)) + model.set({ + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + // else + // toast.show({ + // type: "warning", + // message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, + // duration: 3000, + // }) + } + }) + const agent = (() => { const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) const [store, setStore] = createStore<{ @@ -76,11 +107,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ })() const model = (() => { - const list = createMemo(() => - sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)), - ) - const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) - const [store, setStore] = createStore<{ model: Record<string, ModelKey> recent: ModelKey[] @@ -95,27 +121,54 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ localStorage.setItem("model", JSON.stringify(store.recent)) }) - const fallback = createMemo(() => { - if (store.recent.length) return store.recent[0] + const list = createMemo(() => + sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)), + ) + const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) + + const fallbackModel = createMemo(() => { + if (sync.data.config.model) { + const [providerID, modelID] = sync.data.config.model.split("/") + if (isModelValid({ providerID, modelID })) { + return { + providerID, + modelID, + } + } + } + + for (const item of store.recent) { + if (isModelValid(item)) { + return item + } + } const provider = sync.data.provider[0] const model = Object.values(provider.models)[0] - return { modelID: model.id, providerID: provider.id } + return { + providerID: provider.id, + modelID: model.id, + } }) - const current = createMemo(() => { + const currentModel = createMemo(() => { const a = agent.current() - return find(store.model[agent.current().name]) ?? find(a.model ?? fallback()) + const key = getFirstValidModel( + () => store.model[a.name], + () => a.model, + fallbackModel, + )! + return find(key) }) const recent = createMemo(() => store.recent.map(find).filter(Boolean)) return { - list, - current, + current: currentModel, recent, + list, set(model: ModelKey | undefined, options?: { recent?: boolean }) { batch(() => { - setStore("model", agent.current().name, model ?? fallback()) + setStore("model", agent.current().name, model ?? fallbackModel()) if (options?.recent && model) { const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) if (uniq.length > 5) uniq.pop() @@ -279,10 +332,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } break case "file.watcher.updated": - setTimeout(sync.load.changes, 1000) - const relativePath = relative(event.properties.file) - if (relativePath.startsWith(".git/")) return - load(relativePath) + // setTimeout(sync.load.changes, 1000) + // const relativePath = relative(event.properties.file) + // if (relativePath.startsWith(".git/")) return + // load(relativePath) break } }) @@ -480,8 +533,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const getMessageText = (message: Message | Message[] | undefined): string => { if (!message) return "" if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ") - const fileParts = sync.data.part[message.id]?.filter((p) => p.type === "file") - return sync.data.part[message.id] ?.filter((p) => p.type === "text") ?.filter((p) => !p.synthetic) diff --git a/packages/desktop/src/context/marked.tsx b/packages/desktop/src/context/marked.tsx deleted file mode 100644 index 18ce4280a..000000000 --- a/packages/desktop/src/context/marked.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { marked } from "marked" -import markedShiki from "marked-shiki" -import { bundledLanguages, type BundledLanguage } from "shiki" - -import { createSimpleContext } from "./helper" -import { useShiki } from "./shiki" - -export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ - name: "Marked", - init: () => { - const highlighter = useShiki() - return marked.use( - markedShiki({ - async highlight(code, lang) { - if (!(lang in bundledLanguages)) { - lang = "text" - } - if (!highlighter.getLoadedLanguages().includes(lang)) { - await highlighter.loadLanguage(lang as BundledLanguage) - } - return highlighter.codeToHtml(code, { - lang: lang || "text", - theme: "opencode", - tabindex: false, - }) - }, - }), - ) - }, -}) diff --git a/packages/desktop/src/context/shiki.tsx b/packages/desktop/src/context/shiki.tsx deleted file mode 100644 index b6c278bfe..000000000 --- a/packages/desktop/src/context/shiki.tsx +++ /dev/null @@ -1,573 +0,0 @@ -import { createSimpleContext } from "./helper" -import { createHighlighter, type ThemeInput } from "shiki" - -const theme: ThemeInput = { - colors: { - "actionBar.toggledBackground": "var(--surface-raised-base)", - "activityBarBadge.background": "var(--surface-brand-base)", - "checkbox.border": "var(--border-base)", - "editor.background": "transparent", - "editor.foreground": "var(--text-base)", - "editor.inactiveSelectionBackground": "var(--surface-raised-base)", - "editor.selectionHighlightBackground": "var(--border-active)", - "editorIndentGuide.activeBackground1": "var(--border-weak-base)", - "editorIndentGuide.background1": "var(--border-weak-base)", - "input.placeholderForeground": "var(--text-weak)", - "list.activeSelectionIconForeground": "var(--text-base)", - "list.dropBackground": "var(--surface-raised-base)", - "menu.background": "var(--surface-base)", - "menu.border": "var(--border-base)", - "menu.foreground": "var(--text-base)", - "menu.selectionBackground": "var(--surface-interactive-base)", - "menu.separatorBackground": "var(--border-base)", - "ports.iconRunningProcessForeground": "var(--icon-success-base)", - "sideBarSectionHeader.background": "transparent", - "sideBarSectionHeader.border": "var(--border-weak-base)", - "sideBarTitle.foreground": "var(--text-weak)", - "statusBarItem.remoteBackground": "var(--surface-success-base)", - "statusBarItem.remoteForeground": "var(--text-base)", - "tab.lastPinnedBorder": "var(--border-weak-base)", - "tab.selectedBackground": "var(--surface-raised-base)", - "tab.selectedForeground": "var(--text-weak)", - "terminal.inactiveSelectionBackground": "var(--surface-raised-base)", - "widget.border": "var(--border-base)", - }, - displayName: "opencode", - name: "opencode", - semanticHighlighting: true, - semanticTokenColors: { - customLiteral: "var(--syntax-function)", - newOperator: "var(--syntax-operator)", - numberLiteral: "var(--syntax-number)", - stringLiteral: "var(--syntax-string)", - }, - tokenColors: [ - { - scope: [ - "meta.embedded", - "source.groovy.embedded", - "string meta.image.inline.markdown", - "variable.legacy.builtin.python", - ], - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: "emphasis", - settings: { - fontStyle: "italic", - }, - }, - { - scope: "strong", - settings: { - fontStyle: "bold", - }, - }, - { - scope: "header", - settings: { - foreground: "var(--markdown-heading)", - }, - }, - { - scope: "comment", - settings: { - foreground: "var(--syntax-comment)", - }, - }, - { - scope: "constant.language", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: [ - "constant.numeric", - "variable.other.enummember", - "keyword.operator.plus.exponent", - "keyword.operator.minus.exponent", - ], - settings: { - foreground: "var(--syntax-number)", - }, - }, - { - scope: "constant.regexp", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "entity.name.tag", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["entity.name.tag.css", "entity.name.tag.less"], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "entity.other.attribute-name", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: [ - "entity.other.attribute-name.class.css", - "source.css entity.other.attribute-name.class", - "entity.other.attribute-name.id.css", - "entity.other.attribute-name.parent-selector.css", - "entity.other.attribute-name.parent.less", - "source.css entity.other.attribute-name.pseudo-class", - "entity.other.attribute-name.pseudo-element.css", - "source.css.less entity.other.attribute-name.id", - "entity.other.attribute-name.scss", - ], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "invalid", - settings: { - foreground: "var(--syntax-critical)", - }, - }, - { - scope: "markup.underline", - settings: { - fontStyle: "underline", - }, - }, - { - scope: "markup.bold", - settings: { - fontStyle: "bold", - foreground: "var(--markdown-strong)", - }, - }, - { - scope: "markup.heading", - settings: { - fontStyle: "bold", - foreground: "var(--theme-markdown-heading)", - }, - }, - { - scope: "markup.italic", - settings: { - fontStyle: "italic", - }, - }, - { - scope: "markup.strikethrough", - settings: { - fontStyle: "strikethrough", - }, - }, - { - scope: "markup.inserted", - settings: { - foreground: "var(--text-diff-add-base)", - }, - }, - { - scope: "markup.deleted", - settings: { - foreground: "var(--text-diff-delete-base)", - }, - }, - { - scope: "markup.changed", - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: "punctuation.definition.quote.begin.markdown", - settings: { - foreground: "var(--markdown-block-quote)", - }, - }, - { - scope: "punctuation.definition.list.begin.markdown", - settings: { - foreground: "var(--markdown-list-enumeration)", - }, - }, - { - scope: "markup.inline.raw", - settings: { - foreground: "var(--markdown-code)", - }, - }, - { - scope: "punctuation.definition.tag", - settings: { - foreground: "var(--syntax-punctuation)", - }, - }, - { - scope: ["meta.preprocessor", "entity.name.function.preprocessor"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "meta.preprocessor.string", - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "meta.preprocessor.numeric", - settings: { - foreground: "var(--syntax-number)", - }, - }, - { - scope: "meta.structure.dictionary.key.python", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "meta.diff.header", - settings: { - foreground: "var(--text-weak)", - }, - }, - { - scope: "storage", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "storage.type", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["storage.modifier", "keyword.operator.noexcept"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["string", "meta.embedded.assembly"], - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "string.tag", - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "string.value", - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "string.regexp", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: [ - "punctuation.definition.template-expression.begin", - "punctuation.definition.template-expression.end", - "punctuation.section.embedded", - ], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["meta.template.expression"], - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: [ - "support.type.vendored.property-name", - "support.type.property-name", - "source.css variable", - "source.coffee.embedded", - ], - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "keyword", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "keyword.control", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "keyword.operator", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: [ - "keyword.operator.new", - "keyword.operator.expression", - "keyword.operator.cast", - "keyword.operator.sizeof", - "keyword.operator.alignof", - "keyword.operator.typeid", - "keyword.operator.alignas", - "keyword.operator.instanceof", - "keyword.operator.logical.python", - "keyword.operator.wordlike", - ], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "keyword.other.unit", - settings: { - foreground: "var(--syntax-number)", - }, - }, - { - scope: ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "support.function.git-rebase", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "constant.sha.git-rebase", - settings: { - foreground: "var(--syntax-number)", - }, - }, - { - scope: ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"], - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: "variable.language", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: [ - "entity.name.function", - "support.function", - "support.constant.handlebars", - "source.powershell variable.other.member", - "entity.name.operator.custom-literal", - ], - settings: { - foreground: "var(--syntax-function)", - }, - }, - { - scope: [ - "support.class", - "support.type", - "entity.name.type", - "entity.name.namespace", - "entity.other.attribute", - "entity.name.scope-resolution", - "entity.name.class", - "storage.type.numeric.go", - "storage.type.byte.go", - "storage.type.boolean.go", - "storage.type.string.go", - "storage.type.uintptr.go", - "storage.type.error.go", - "storage.type.rune.go", - "storage.type.cs", - "storage.type.generic.cs", - "storage.type.modifier.cs", - "storage.type.variable.cs", - "storage.type.annotation.java", - "storage.type.generic.java", - "storage.type.java", - "storage.type.object.array.java", - "storage.type.primitive.array.java", - "storage.type.primitive.java", - "storage.type.token.java", - "storage.type.groovy", - "storage.type.annotation.groovy", - "storage.type.parameters.groovy", - "storage.type.generic.groovy", - "storage.type.object.array.groovy", - "storage.type.primitive.array.groovy", - "storage.type.primitive.groovy", - ], - settings: { - foreground: "var(--syntax-type)", - }, - }, - { - scope: [ - "meta.type.cast.expr", - "meta.type.new.expr", - "support.constant.math", - "support.constant.dom", - "support.constant.json", - "entity.other.inherited-class", - "punctuation.separator.namespace.ruby", - ], - settings: { - foreground: "var(--syntax-type)", - }, - }, - { - scope: [ - "keyword.control", - "source.cpp keyword.operator.new", - "keyword.operator.delete", - "keyword.other.using", - "keyword.other.directive.using", - "keyword.other.operator", - "entity.name.operator", - ], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: [ - "variable", - "meta.definition.variable.name", - "support.variable", - "entity.name.variable", - "constant.other.placeholder", - ], - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: ["variable.other.constant", "variable.other.enummember"], - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: ["meta.object-literal.key"], - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: [ - "support.constant.property-value", - "support.constant.font-name", - "support.constant.media-type", - "support.constant.media", - "constant.other.color.rgb-value", - "constant.other.rgb-value", - "support.constant.color", - ], - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: [ - "punctuation.definition.group.regexp", - "punctuation.definition.group.assertion.regexp", - "punctuation.definition.character-class.regexp", - "punctuation.character.set.begin.regexp", - "punctuation.character.set.end.regexp", - "keyword.operator.negation.regexp", - "support.other.parenthesis.regexp", - ], - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: [ - "constant.character.character-class.regexp", - "constant.other.character-class.set.regexp", - "constant.other.character-class.regexp", - "constant.character.set.regexp", - ], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "keyword.operator.quantifier.regexp", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: ["constant.character", "constant.other.option"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "constant.character.escape", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "entity.name.label", - settings: { - foreground: "var(--text-weak)", - }, - }, - ], - type: "dark", -} - -const highlighter = await createHighlighter({ - themes: [theme], - langs: [], -}) - -export const { use: useShiki, provider: ShikiProvider } = createSimpleContext({ - name: "Shiki", - init: () => { - return highlighter - }, -}) diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index 12aa95878..ce2fa1ea4 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -77,21 +77,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } case "message.part.updated": { - const parts = store.part[event.properties.part.messageID] + const part = sanitizePart(event.properties.part) + const parts = store.part[part.messageID] if (!parts) { - setStore("part", event.properties.part.messageID, [event.properties.part]) + setStore("part", part.messageID, [part]) break } - const result = Binary.search(parts, event.properties.part.id, (p) => p.id) + const result = Binary.search(parts, part.id, (p) => p.id) if (result.found) { - setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) + setStore("part", part.messageID, result.index, reconcile(part)) break } setStore( "part", - event.properties.part.messageID, + part.messageID, produce((draft) => { - draft.splice(result.index, 0, event.properties.part) + draft.splice(result.index, 0, part) }), ) break @@ -121,6 +122,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g")) const sanitize = (text: string) => text.replace(sanitizer(), "") const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") + const sanitizePart = (part: Part) => { + if (part.type === "tool") { + if (part.state.status === "completed") { + for (const key in part.state.metadata) { + if (typeof part.state.metadata[key] === "string") { + part.state.metadata[key] = sanitize(part.state.metadata[key] as string) + } + } + for (const key in part.state.input) { + if (typeof part.state.input[key] === "string") { + part.state.input[key] = sanitize(part.state.input[key] as string) + } + } + } + } + return part + } return { data: store, @@ -155,7 +173,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .slice() .sort((a, b) => a.id.localeCompare(b.id)) for (const message of messages.data!) { - draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) + draft.part[message.info.id] = message.parts + .slice() + .map(sanitizePart) + .sort((a, b) => a.id.localeCompare(b.id)) } }), ) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 9c7a07fe6..0d631a5a0 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -3,9 +3,7 @@ import "@/index.css" import { render } from "solid-js/web" import { Router, Route } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" -import { Fonts } from "@opencode-ai/ui" -import { ShikiProvider } from "./context/shiki" -import { MarkedProvider } from "./context/marked" +import { Fonts, ShikiProvider, MarkedProvider } from "@opencode-ai/ui" import { SDKProvider } from "./context/sdk" import { SyncProvider } from "./context/sync" import { LocalProvider } from "./context/local" diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 5216c4272..5237d78bb 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -9,6 +9,10 @@ import { Accordion, Diff, Collapsible, + Part, + DiffChanges, + ProgressCircle, + Message, } from "@opencode-ai/ui" import { FileIcon } from "@/ui" import FileTree from "@/components/file-tree" @@ -32,11 +36,8 @@ import type { JSX } from "solid-js" 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 { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" -import { DiffChanges } from "@/components/diff-changes" -import { Markdown } from "@/components/markdown" +import { Markdown } from "@opencode-ai/ui" export default function Page() { const local = useLocal() @@ -271,7 +272,7 @@ export default function Page() { const TabVisual = (props: { file: LocalFile }): JSX.Element => { return ( <div class="flex items-center gap-x-1.5"> - <FileIcon node={props.file} class="_grayscale-100" /> + <FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" /> <span classList={{ "text-14-medium": true, @@ -310,15 +311,21 @@ export default function Page() { <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}> <Tooltip value={props.file.path} placement="bottom" class="h-full"> <div class="relative h-full"> - <Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}> + <Tabs.Trigger + value={props.file.path} + class="group/tab pl-3 pr-1" + onClick={() => props.onTabClick(props.file)} + > <TabVisual file={props.file} /> + <IconButton + icon="close" + class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100 + group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle + hover:opacity-100 group-hover/tab:opacity-100" + variant="ghost" + onClick={() => props.onTabClose(props.file)} + /> </Tabs.Trigger> - <IconButton - icon="close" - class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100" - variant="ghost" - onClick={() => props.onTabClose(props.file)} - /> </div> </Tooltip> </div> @@ -377,6 +384,7 @@ export default function Page() { {(session) => { const diffs = createMemo(() => session.summary?.diffs ?? []) const filesChanged = createMemo(() => diffs().length) + const updated = DateTime.fromMillis(session.time.updated) return ( <Tooltip placement="right" value={session.title}> <div> @@ -385,7 +393,14 @@ export default function Page() { {session.title} </span> <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> - {DateTime.fromMillis(session.time.updated).toRelative()} + {Math.abs(updated.diffNow().as("seconds")) < 60 + ? "Now" + : updated + .toRelative({ style: "short", unit: ["days", "hours", "minutes"] }) + ?.replace(" ago", "") + ?.replace(/ days?/, "d") + ?.replace(" min.", "m") + ?.replace(" hr.", "h")} </span> </div> <div class="flex justify-between items-center self-stretch"> @@ -497,7 +512,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" /> @@ -528,101 +543,14 @@ export default function Page() { > <For each={local.session.userMessages()}> {(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) - }) + const diffs = createMemo(() => message.summary?.diffs ?? []) return ( <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> - <For each={visibleBlocks()}> - {(color, i) => ( - <rect x={i() * 4} width="2" height="12" rx="1" fill={color} /> - )} - </For> - </g> - </svg> - </div> + <DiffChanges diff={diffs()} variant="bars" /> <div data-active={local.session.activeMessage()?.id === message.id} classList={{ @@ -660,7 +588,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> @@ -825,7 +753,7 @@ export default function Page() { </div> </Collapsible.Trigger> <Collapsible.Content> - <div class="w-full flex flex-col items-start self-stretch gap-8"> + <div class="w-full flex flex-col items-start self-stretch gap-3"> <For each={assistantMessages()}> {(assistantMessage) => { const parts = createMemo( @@ -873,7 +801,7 @@ export default function Page() { const draggedFile = local.file.node(id) if (!draggedFile) return null return ( - <div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent"> + <div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent"> <TabVisual file={draggedFile} /> </div> ) |
