summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-02 06:50:16 -0600
committerAdam <[email protected]>2025-12-02 06:50:21 -0600
commitc0a35141e6b70eed1a9ba576fe43b7f7d690b968 (patch)
tree50d3f50af029076faa3f5d4a5d9a9acb752e6b6e /packages/ui/src/components
parent221bb64aebda8263ea2ce48c1a1dc1f36ac83857 (diff)
downloadopencode-c0a35141e6b70eed1a9ba576fe43b7f7d690b968.tar.gz
opencode-c0a35141e6b70eed1a9ba576fe43b7f7d690b968.zip
feat: better code and diff rendering performance
Diffstat (limited to 'packages/ui/src/components')
-rw-r--r--packages/ui/src/components/code.tsx29
-rw-r--r--packages/ui/src/components/diff-ssr.tsx75
-rw-r--r--packages/ui/src/components/diff.tsx98
-rw-r--r--packages/ui/src/components/message-part.tsx32
-rw-r--r--packages/ui/src/components/pierre.ts68
-rw-r--r--packages/ui/src/components/session-review.tsx8
-rw-r--r--packages/ui/src/components/session-turn.tsx31
7 files changed, 183 insertions, 158 deletions
diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx
index 788baf549..b4b772816 100644
--- a/packages/ui/src/components/code.tsx
+++ b/packages/ui/src/components/code.tsx
@@ -1,6 +1,22 @@
import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/precision-diffs"
import { ComponentProps, createEffect, splitProps } from "solid-js"
-import { createDefaultOptions, styleVariables } from "./pierre"
+import { createDefaultOptions, styleVariables } from "../pierre"
+import { getOrCreateWorkerPoolSingleton } from "@pierre/precision-diffs/worker"
+import { workerFactory } from "../pierre/worker"
+
+const workerPool = getOrCreateWorkerPoolSingleton({
+ poolOptions: {
+ workerFactory,
+ // poolSize defaults to 8. More workers = more parallelism but
+ // also more memory. Too many can actually slow things down.
+ // poolSize: 8,
+ },
+ highlighterOptions: {
+ theme: "OpenCode",
+ // Optionally preload languages to avoid lazy-loading delays
+ // langs: ["typescript", "javascript", "css", "html"],
+ },
+})
export type CodeProps<T = {}> = FileOptions<T> & {
file: FileContents
@@ -14,10 +30,13 @@ export function Code<T>(props: CodeProps<T>) {
const [local, others] = splitProps(props, ["file", "class", "classList", "annotations"])
createEffect(() => {
- const instance = new File<T>({
- ...createDefaultOptions<T>("unified"),
- ...others,
- })
+ const instance = new File<T>(
+ {
+ ...createDefaultOptions<T>("unified"),
+ ...others,
+ },
+ workerPool,
+ )
container.innerHTML = ""
instance.render({
diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx
new file mode 100644
index 000000000..800aa3730
--- /dev/null
+++ b/packages/ui/src/components/diff-ssr.tsx
@@ -0,0 +1,75 @@
+import { FileDiff } from "@pierre/precision-diffs"
+import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
+import { onCleanup, onMount, Show, splitProps } from "solid-js"
+import { isServer } from "solid-js/web"
+import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
+
+export type SSRDiffProps<T = {}> = DiffProps<T> & {
+ preloadedDiff: PreloadMultiFileDiffResult<T>
+}
+
+export function Diff<T>(props: SSRDiffProps<T>) {
+ let container!: HTMLDivElement
+ let fileDiffRef!: HTMLElement
+ const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"])
+
+ let fileDiffInstance: FileDiff<T> | undefined
+ const cleanupFunctions: Array<() => void> = []
+
+ onMount(() => {
+ if (isServer || !props.preloadedDiff) return
+ fileDiffInstance = new FileDiff<T>({
+ ...createDefaultOptions(props.diffStyle),
+ ...others,
+ ...props.preloadedDiff,
+ })
+ // @ts-expect-error - fileContainer is private but needed for SSR hydration
+ fileDiffInstance.fileContainer = fileDiffRef
+ fileDiffInstance.hydrate({
+ oldFile: local.before,
+ newFile: local.after,
+ lineAnnotations: local.annotations,
+ fileContainer: fileDiffRef,
+ containerWrapper: container,
+ })
+
+ // Hydrate annotation slots with interactive SolidJS components
+ // if (props.annotations.length > 0 && props.renderAnnotation != null) {
+ // for (const annotation of props.annotations) {
+ // const slotName = `annotation-${annotation.side}-${annotation.lineNumber}`;
+ // const slotElement = fileDiffRef.querySelector(
+ // `[slot="${slotName}"]`
+ // ) as HTMLElement;
+ //
+ // if (slotElement != null) {
+ // // Clear the static server-rendered content from the slot
+ // slotElement.innerHTML = '';
+ //
+ // // Mount a fresh SolidJS component into this slot using render().
+ // // This enables full SolidJS reactivity (signals, effects, etc.)
+ // const dispose = render(
+ // () => props.renderAnnotation!(annotation),
+ // slotElement
+ // );
+ // cleanupFunctions.push(dispose);
+ // }
+ // }
+ // }
+ })
+
+ onCleanup(() => {
+ // Clean up FileDiff event handlers and dispose SolidJS components
+ fileDiffInstance?.cleanUp()
+ cleanupFunctions.forEach((dispose) => dispose())
+ })
+
+ return (
+ <div data-component="diff" style={styleVariables} ref={container}>
+ <file-diff ref={fileDiffRef} id="ssr-diff">
+ <Show when={isServer}>
+ <template shadowrootmode="open" innerHTML={props.preloadedDiff.prerenderedHTML} />
+ </Show>
+ </file-diff>
+ </div>
+ )
+}
diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx
index bd2134515..8e19c3172 100644
--- a/packages/ui/src/components/diff.tsx
+++ b/packages/ui/src/components/diff.tsx
@@ -1,17 +1,22 @@
-import { type FileContents, FileDiff, type DiffLineAnnotation, FileDiffOptions } from "@pierre/precision-diffs"
-import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
-import { ComponentProps, createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
-import { isServer } from "solid-js/web"
-import { createDefaultOptions, styleVariables } from "./pierre"
-
-export type DiffProps<T = {}> = FileDiffOptions<T> & {
- preloadedDiff?: PreloadMultiFileDiffResult<T>
- before: FileContents
- after: FileContents
- annotations?: DiffLineAnnotation<T>[]
- class?: string
- classList?: ComponentProps<"div">["classList"]
-}
+import { FileDiff } from "@pierre/precision-diffs"
+import { getOrCreateWorkerPoolSingleton } from "@pierre/precision-diffs/worker"
+import { createEffect, onCleanup, splitProps } from "solid-js"
+import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
+import { workerFactory } from "../pierre/worker"
+
+const workerPool = getOrCreateWorkerPoolSingleton({
+ poolOptions: {
+ workerFactory,
+ // poolSize defaults to 8. More workers = more parallelism but
+ // also more memory. Too many can actually slow things down.
+ // poolSize: 8,
+ },
+ highlighterOptions: {
+ theme: "OpenCode",
+ // Optionally preload languages to avoid lazy-loading delays
+ // langs: ["typescript", "javascript", "css", "html"],
+ },
+})
// interface ThreadMetadata {
// threadId: string
@@ -21,21 +26,21 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
export function Diff<T>(props: DiffProps<T>) {
let container!: HTMLDivElement
- let fileDiffRef!: HTMLElement
const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"])
let fileDiffInstance: FileDiff<T> | undefined
const cleanupFunctions: Array<() => void> = []
createEffect(() => {
- if (props.preloadedDiff) return
container.innerHTML = ""
if (!fileDiffInstance) {
- fileDiffInstance = new FileDiff<T>({
- ...createDefaultOptions(props.diffStyle),
- ...others,
- ...(props.preloadedDiff ?? {}),
- })
+ fileDiffInstance = new FileDiff<T>(
+ {
+ ...createDefaultOptions(props.diffStyle),
+ ...others,
+ },
+ workerPool,
+ )
}
fileDiffInstance.render({
oldFile: local.before,
@@ -45,60 +50,11 @@ export function Diff<T>(props: DiffProps<T>) {
})
})
- onMount(() => {
- if (isServer || !props.preloadedDiff) return
- fileDiffInstance = new FileDiff<T>({
- ...createDefaultOptions(props.diffStyle),
- ...others,
- ...(props.preloadedDiff ?? {}),
- })
- // @ts-expect-error - fileContainer is private but needed for SSR hydration
- fileDiffInstance.fileContainer = fileDiffRef
- fileDiffInstance.hydrate({
- oldFile: local.before,
- newFile: local.after,
- lineAnnotations: local.annotations,
- fileContainer: fileDiffRef,
- containerWrapper: container,
- })
-
- // Hydrate annotation slots with interactive SolidJS components
- // if (props.annotations.length > 0 && props.renderAnnotation != null) {
- // for (const annotation of props.annotations) {
- // const slotName = `annotation-${annotation.side}-${annotation.lineNumber}`;
- // const slotElement = fileDiffRef.querySelector(
- // `[slot="${slotName}"]`
- // ) as HTMLElement;
- //
- // if (slotElement != null) {
- // // Clear the static server-rendered content from the slot
- // slotElement.innerHTML = '';
- //
- // // Mount a fresh SolidJS component into this slot using render().
- // // This enables full SolidJS reactivity (signals, effects, etc.)
- // const dispose = render(
- // () => props.renderAnnotation!(annotation),
- // slotElement
- // );
- // cleanupFunctions.push(dispose);
- // }
- // }
- // }
- })
-
onCleanup(() => {
// Clean up FileDiff event handlers and dispose SolidJS components
fileDiffInstance?.cleanUp()
cleanupFunctions.forEach((dispose) => dispose())
})
- return (
- <div data-component="diff" style={styleVariables} ref={container}>
- <file-diff ref={fileDiffRef} id="ssr-diff">
- <Show when={isServer && props.preloadedDiff}>
- {(preloadedDiff) => <template shadowrootmode="open" innerHTML={preloadedDiff().prerenderedHTML} />}
- </Show>
- </file-diff>
- </div>
- )
+ return <div data-component="diff" style={styleVariables} ref={container} />
}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 807e56db0..a0e6e91b6 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -1,4 +1,4 @@
-import { Component, createMemo, For, Match, Show, Switch } from "solid-js"
+import { Component, createMemo, For, Match, Show, Switch, ValidComponent } from "solid-js"
import { Dynamic } from "solid-js/web"
import {
AssistantMessage,
@@ -13,7 +13,6 @@ import { GenericTool } from "./basic-tool"
import { Card } from "./card"
import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
-import { Diff } from "./diff"
import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
@@ -23,12 +22,14 @@ import { unwrap } from "solid-js/store"
export interface MessageProps {
message: MessageType
parts: PartType[]
+ diffComponent: ValidComponent
sanitize?: RegExp
}
export interface MessagePartProps {
part: PartType
message: MessageType
+ diffComponent: ValidComponent
hideDetails?: boolean
sanitize?: RegExp
}
@@ -53,6 +54,7 @@ export function Message(props: MessageProps) {
message={assistantMessage() as AssistantMessage}
parts={props.parts}
sanitize={props.sanitize}
+ diffComponent={props.diffComponent}
/>
)}
</Match>
@@ -60,7 +62,12 @@ export function Message(props: MessageProps) {
)
}
-export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[]; sanitize?: RegExp }) {
+export function AssistantMessageDisplay(props: {
+ message: AssistantMessage
+ parts: PartType[]
+ sanitize?: RegExp
+ diffComponent: ValidComponent
+}) {
const filteredParts = createMemo(() => {
return props.parts?.filter((x) => {
if (x.type === "reasoning") return false
@@ -68,7 +75,11 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part
})
})
return (
- <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} sanitize={props.sanitize} />}</For>
+ <For each={filteredParts()}>
+ {(part) => (
+ <Part part={part} message={props.message} sanitize={props.sanitize} diffComponent={props.diffComponent} />
+ )}
+ </For>
)
}
@@ -87,7 +98,13 @@ export function Part(props: MessagePartProps) {
const part = createMemo(() => sanitizePart(unwrap(props.part), props.sanitize))
return (
<Show when={component()}>
- <Dynamic component={component()} part={part()} message={props.message} hideDetails={props.hideDetails} />
+ <Dynamic
+ component={component()}
+ part={part()}
+ message={props.message}
+ diffComponent={props.diffComponent}
+ hideDetails={props.hideDetails}
+ />
</Show>
)
}
@@ -96,6 +113,7 @@ export interface ToolProps {
input: Record<string, any>
metadata: Record<string, any>
tool: string
+ diffComponent: ValidComponent
output?: string
hideDetails?: boolean
}
@@ -162,6 +180,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
component={render}
input={input}
tool={part.tool}
+ diffComponent={props.diffComponent}
metadata={metadata}
output={part.state.status === "completed" ? part.state.output : undefined}
hideDetails={props.hideDetails}
@@ -361,7 +380,8 @@ ToolRegistry.register({
>
<Show when={props.metadata.filediff}>
<div data-component="edit-content">
- <Diff
+ <Dynamic
+ component={props.diffComponent}
before={{
name: getFilename(props.metadata.filediff.path),
contents: props.metadata.filediff.before,
diff --git a/packages/ui/src/components/pierre.ts b/packages/ui/src/components/pierre.ts
deleted file mode 100644
index 5821697c7..000000000
--- a/packages/ui/src/components/pierre.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { FileDiffOptions } from "@pierre/precision-diffs"
-
-export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
- return {
- theme: "OpenCode",
- themeType: "system",
- disableLineNumbers: false,
- overflow: "wrap",
- diffStyle: style ?? "unified",
- diffIndicators: "bars",
- disableBackground: false,
- expansionLineCount: 20,
- lineDiffType: style === "split" ? "word-alt" : "none",
- maxLineDiffLength: 1000,
- maxLineLengthForHighlighting: 1000,
- disableFileHeader: true,
- unsafeCSS: `
-[data-pjs-header],
-[data-pjs] {
- [data-separator-wrapper] {
- margin: 0 !important;
- border-radius: 0 !important;
- }
- [data-expand-button] {
- width: 6.5ch !important;
- height: 24px !important;
- justify-content: end !important;
- padding-left: 3ch !important;
- padding-inline: 1ch !important;
- }
- [data-separator-multi-button] {
- grid-template-rows: 10px 10px !important;
- [data-expand-button] {
- height: 12px !important;
- }
- }
- [data-separator-content] {
- height: 24px !important;
- }
-}`,
- // hunkSeparators(hunkData: HunkData) {
- // const fragment = document.createDocumentFragment()
- // const numCol = document.createElement("div")
- // numCol.innerHTML = `<svg data-slot="diff-hunk-separator-line-number-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.97978 14.0204L8.62623 13.6668L9.33334 12.9597L9.68689 13.3133L9.33333 13.6668L8.97978 14.0204ZM12 16.3335L12.3535 16.6871L12 17.0406L11.6464 16.687L12 16.3335ZM14.3131 13.3133L14.6667 12.9597L15.3738 13.6668L15.0202 14.0204L14.6667 13.6668L14.3131 13.3133ZM12.5 16.0002V16.5002H11.5V16.0002H12H12.5ZM9.33333 13.6668L9.68689 13.3133L12.3535 15.9799L12 16.3335L11.6464 16.687L8.97978 14.0204L9.33333 13.6668ZM12 16.3335L11.6464 15.9799L14.3131 13.3133L14.6667 13.6668L15.0202 14.0204L12.3535 16.6871L12 16.3335ZM6.5 8.00016V7.50016H8.5V8.00016V8.50016H6.5V8.00016ZM9.5 8.00016V7.50016H11.5V8.00016V8.50016H9.5V8.00016ZM12.5 8.00016V7.50016H14.5V8.00016V8.50016H12.5V8.00016ZM15.5 8.00016V7.50016H17.5V8.00016V8.50016H15.5V8.00016ZM12 10.5002H12.5V16.0002H12H11.5V10.5002H12Z" fill="currentColor"/></svg> `
- // numCol.dataset["slot"] = "diff-hunk-separator-line-number"
- // fragment.appendChild(numCol)
- // const contentCol = document.createElement("div")
- // contentCol.dataset["slot"] = "diff-hunk-separator-content"
- // const span = document.createElement("span")
- // span.dataset["slot"] = "diff-hunk-separator-content-span"
- // span.textContent = `${hunkData.lines} unmodified lines`
- // contentCol.appendChild(span)
- // fragment.appendChild(contentCol)
- // return fragment
- // },
- } as const
-}
-
-export const styleVariables = {
- "--pjs-font-family": "var(--font-family-mono)",
- "--pjs-font-size": "var(--font-size-small)",
- "--pjs-line-height": "24px",
- "--pjs-tab-size": 2,
- "--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
- "--pjs-header-font-family": "var(--font-family-sans)",
- "--pjs-gap-block": 0,
- "--pjs-min-number-column-width": "4ch",
-}
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 376317e1b..ea5871b95 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -1,15 +1,15 @@
import { Accordion } from "./accordion"
import { Button } from "./button"
-import { Diff } from "./diff"
import { DiffChanges } from "./diff-changes"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { For, Match, Show, Switch, type JSX } from "solid-js"
+import { For, Match, Show, Switch, ValidComponent, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { type FileDiff } from "@opencode-ai/sdk"
import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
+import { Dynamic } from "solid-js/web"
export interface SessionReviewProps {
split?: boolean
@@ -18,6 +18,7 @@ export interface SessionReviewProps {
classes?: { root?: string; header?: string; container?: string }
actions?: JSX.Element
diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> })[]
+ diffComponent: ValidComponent
}
export const SessionReview = (props: SessionReviewProps) => {
@@ -96,7 +97,8 @@ export const SessionReview = (props: SessionReviewProps) => {
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-review-accordion-content">
- <Diff
+ <Dynamic
+ component={props.diffComponent}
preloadedDiff={diff.preloaded}
diffStyle={props.split ? "split" : "unified"}
before={{
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index c61b23068..2dc83a9a7 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -2,7 +2,18 @@ import { AssistantMessage } from "@opencode-ai/sdk"
import { useData } from "../context"
import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { createEffect, createMemo, createSignal, For, Match, onMount, ParentProps, Show, Switch } from "solid-js"
+import {
+ createEffect,
+ createMemo,
+ createSignal,
+ For,
+ Match,
+ onMount,
+ ParentProps,
+ Show,
+ Switch,
+ ValidComponent,
+} from "solid-js"
import { DiffChanges } from "./diff-changes"
import { Typewriter } from "./typewriter"
import { Message } from "./message-part"
@@ -11,10 +22,10 @@ import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
-import { Diff } from "./diff"
import { Card } from "./card"
import { MessageProgress } from "./message-progress"
import { Collapsible } from "./collapsible"
+import { Dynamic } from "solid-js/web"
export function SessionTurn(
props: ParentProps<{
@@ -25,6 +36,7 @@ export function SessionTurn(
content?: string
container?: string
}
+ diffComponent: ValidComponent
}>,
) {
const data = useData()
@@ -117,7 +129,7 @@ export function SessionTurn(
</div>
</div>
<div data-slot="session-turn-message-content">
- <Message message={msg()} parts={parts()} sanitize={sanitizer()} />
+ <Message message={msg()} parts={parts()} sanitize={sanitizer()} diffComponent={props.diffComponent} />
</div>
{/* Summary */}
<Show when={completed()}>
@@ -167,7 +179,8 @@ export function SessionTurn(
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-turn-accordion-content">
- <Diff
+ <Dynamic
+ component={props.diffComponent}
before={{
name: diff.file!,
contents: diff.before!,
@@ -224,10 +237,18 @@ export function SessionTurn(
message={assistantMessage}
parts={parts().filter((p) => p?.id !== last()?.id)}
sanitize={sanitizer()}
+ diffComponent={props.diffComponent}
/>
)
}
- return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} />
+ return (
+ <Message
+ message={assistantMessage}
+ parts={parts()}
+ sanitize={sanitizer()}
+ diffComponent={props.diffComponent}
+ />
+ )
}}
</For>
<Show when={error()}>