summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2025-12-16 10:54:51 +0000
committerDavid Hill <[email protected]>2025-12-16 10:54:51 +0000
commit05e0759878cb0f24c981c69ae26f6be3ea5583c6 (patch)
tree39aaf6b86a6f2b8e653120e80d8f0facb528051b /packages/ui/src/components
parent2330ec6dc3000ae8b86810e9d59b414ad4f05f47 (diff)
parent75e5130cf8f58b32ee3f3ba2249d5917e7e3d6fc (diff)
downloadopencode-05e0759878cb0f24c981c69ae26f6be3ea5583c6.tar.gz
opencode-05e0759878cb0f24c981c69ae26f6be3ea5583c6.zip
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/ui/src/components')
-rw-r--r--packages/ui/src/components/code.tsx2
-rw-r--r--packages/ui/src/components/dialog.tsx8
-rw-r--r--packages/ui/src/components/diff-ssr.tsx8
-rw-r--r--packages/ui/src/components/diff.css4
-rw-r--r--packages/ui/src/components/diff.tsx2
-rw-r--r--packages/ui/src/components/icon.tsx1
-rw-r--r--packages/ui/src/components/list.css1
-rw-r--r--packages/ui/src/components/list.tsx2
-rw-r--r--packages/ui/src/components/message-part.css106
-rw-r--r--packages/ui/src/components/message-part.tsx151
-rw-r--r--packages/ui/src/components/session-review.tsx2
-rw-r--r--packages/ui/src/components/session-turn.tsx30
12 files changed, 277 insertions, 40 deletions
diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx
index c80f0987f..77696faed 100644
--- a/packages/ui/src/components/code.tsx
+++ b/packages/ui/src/components/code.tsx
@@ -1,4 +1,4 @@
-import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/precision-diffs"
+import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/diffs"
import { ComponentProps, createEffect, createMemo, splitProps } from "solid-js"
import { createDefaultOptions, styleVariables } from "../pierre"
import { workerPool } from "../pierre/worker"
diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx
index 47d6af42e..40a6ac83d 100644
--- a/packages/ui/src/components/dialog.tsx
+++ b/packages/ui/src/components/dialog.tsx
@@ -20,6 +20,14 @@ export function Dialog(props: DialogProps) {
...(props.classList ?? {}),
[props.class ?? ""]: !!props.class,
}}
+ onOpenAutoFocus={(e) => {
+ const target = e.currentTarget as HTMLElement | null
+ const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null
+ if (autofocusEl) {
+ e.preventDefault()
+ autofocusEl.focus()
+ }
+ }}
>
<Show when={props.title || props.action}>
<div data-slot="dialog-header">
diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx
index 800aa3730..b38b4a34f 100644
--- a/packages/ui/src/components/diff-ssr.tsx
+++ b/packages/ui/src/components/diff-ssr.tsx
@@ -1,5 +1,5 @@
-import { FileDiff } from "@pierre/precision-diffs"
-import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
+import { FileDiff } from "@pierre/diffs"
+import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { onCleanup, onMount, Show, splitProps } from "solid-js"
import { isServer } from "solid-js/web"
import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
@@ -65,11 +65,11 @@ export function Diff<T>(props: SSRDiffProps<T>) {
return (
<div data-component="diff" style={styleVariables} ref={container}>
- <file-diff ref={fileDiffRef} id="ssr-diff">
+ <diffs-container ref={fileDiffRef} id="ssr-diff">
<Show when={isServer}>
<template shadowrootmode="open" innerHTML={props.preloadedDiff.prerenderedHTML} />
</Show>
- </file-diff>
+ </diffs-container>
</div>
)
}
diff --git a/packages/ui/src/components/diff.css b/packages/ui/src/components/diff.css
index 690667ea7..345271a12 100644
--- a/packages/ui/src/components/diff.css
+++ b/packages/ui/src/components/diff.css
@@ -19,8 +19,8 @@
position: sticky;
background-color: var(--surface-diff-hidden-base);
color: var(--text-base);
- width: var(--pjs-column-content-width);
- left: var(--pjs-column-number-width);
+ width: var(--diffs-column-content-width);
+ left: var(--diffs-column-number-width);
padding-left: 8px;
user-select: none;
cursor: default;
diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx
index 703043f4c..75dde0440 100644
--- a/packages/ui/src/components/diff.tsx
+++ b/packages/ui/src/components/diff.tsx
@@ -1,4 +1,4 @@
-import { FileDiff } from "@pierre/precision-diffs"
+import { FileDiff } from "@pierre/diffs"
import { createEffect, createMemo, onCleanup, splitProps } from "solid-js"
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
import { workerPool } from "../pierre/worker"
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 0dbd7a650..b8e8106e8 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -51,6 +51,7 @@ const icons = {
"circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`,
copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`,
check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`,
+ photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css
index cd9e73d1d..368065e53 100644
--- a/packages/ui/src/components/list.css
+++ b/packages/ui/src/components/list.css
@@ -112,6 +112,7 @@
padding: 4px 10px;
align-items: center;
color: var(--text-strong);
+ scroll-margin-top: 28px;
/* text-14-medium */
font-family: var(--font-family-sans);
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx
index 7ec6e159d..0ed745f32 100644
--- a/packages/ui/src/components/list.tsx
+++ b/packages/ui/src/components/list.tsx
@@ -79,7 +79,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
return
}
const element = scrollRef()?.querySelector(`[data-key="${active()}"]`)
- element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
+ element?.scrollIntoView({ block: "center", behavior: "smooth" })
})
const handleSelect = (item: T | undefined, index: number) => {
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index e369f9220..01f34ceff 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -14,11 +14,78 @@
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;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ [data-slot="user-message-attachments"] {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ [data-slot="user-message-attachment"] {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ border-radius: 6px;
+ overflow: hidden;
+ background: var(--surface-base);
+ border: 1px solid var(--border-base);
+ transition: border-color 0.15s ease;
+
+ &:hover {
+ border-color: var(--border-strong-base);
+ }
+
+ &[data-type="image"] {
+ width: 48px;
+ height: 48px;
+ }
+
+ &[data-type="file"] {
+ width: 48px;
+ height: 48px;
+ }
+ }
+
+ [data-slot="user-message-attachment-image"] {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ [data-slot="user-message-attachment-icon"] {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--icon-weak);
+
+ [data-component="icon"] {
+ width: 20px;
+ height: 20px;
+ }
+ }
+
+ [data-slot="user-message-text"] {
+ display: -webkit-box;
+ white-space: pre-wrap;
+ line-clamp: 3;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .text-text-strong {
+ color: var(--text-strong);
+ }
+
+ .font-medium {
+ font-weight: var(--font-weight-medium);
+ }
}
[data-component="text-part"] {
@@ -108,15 +175,19 @@
display: flex;
align-items: center;
justify-content: space-between;
+ gap: 8px;
width: 100%;
[data-slot="message-part-title-area"] {
+ flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
+ min-width: 0;
}
[data-slot="message-part-title"] {
+ flex-shrink: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-style: normal;
@@ -129,14 +200,22 @@
[data-slot="message-part-path"] {
display: flex;
+ flex-grow: 1;
+ min-width: 0;
}
[data-slot="message-part-directory"] {
color: var(--text-weak);
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ direction: rtl;
+ text-align: left;
}
[data-slot="message-part-filename"] {
color: var(--text-strong);
+ flex-shrink: 0;
}
[data-slot="message-part-actions"] {
@@ -151,6 +230,23 @@
border-top: 1px solid var(--border-weaker-base);
}
+[data-component="write-content"] {
+ border-top: 1px solid var(--border-weaker-base);
+ max-height: 240px;
+ overflow-y: auto;
+
+ [data-component="code"] {
+ padding-bottom: 0px !important;
+ }
+
+ /* Hide scrollbar */
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
[data-component="tool-action"] {
width: 24px;
height: 24px;
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index f00c43bd8..33b519ea4 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -2,6 +2,7 @@ import { Component, createMemo, For, Match, Show, Switch } from "solid-js"
import { Dynamic } from "solid-js/web"
import {
AssistantMessage,
+ FilePart,
Message as MessageType,
Part as PartType,
TextPart,
@@ -74,13 +75,93 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part
}
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(""),
+ const textPart = createMemo(
+ () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
+ )
+
+ const text = createMemo(() => textPart()?.text || "")
+
+ const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
+
+ const attachments = createMemo(() =>
+ files()?.filter((f) => {
+ const mime = f.mime
+ return mime.startsWith("image/") || mime === "application/pdf"
+ }),
+ )
+
+ const inlineFiles = createMemo(() =>
+ files().filter((f) => {
+ const mime = f.mime
+ return !mime.startsWith("image/") && mime !== "application/pdf" && f.source?.text?.start !== undefined
+ }),
+ )
+
+ return (
+ <div data-component="user-message">
+ <Show when={attachments().length > 0}>
+ <div data-slot="user-message-attachments">
+ <For each={attachments()}>
+ {(file) => (
+ <div data-slot="user-message-attachment" data-type={file.mime.startsWith("image/") ? "image" : "file"}>
+ <Show
+ when={file.mime.startsWith("image/") && file.url}
+ fallback={
+ <div data-slot="user-message-attachment-icon">
+ <Icon name="folder" />
+ </div>
+ }
+ >
+ <img data-slot="user-message-attachment-image" src={file.url} alt={file.filename ?? "attachment"} />
+ </Show>
+ </div>
+ )}
+ </For>
+ </div>
+ </Show>
+ <Show when={text()}>
+ <div data-slot="user-message-text">
+ <HighlightedText text={text()} references={inlineFiles()} />
+ </div>
+ </Show>
+ </div>
+ )
+}
+
+function HighlightedText(props: { text: string; references: FilePart[] }) {
+ const segments = createMemo(() => {
+ const text = props.text
+ const refs = [...props.references].sort((a, b) => (a.source?.text?.start ?? 0) - (b.source?.text?.start ?? 0))
+
+ const result: { text: string; highlight?: boolean }[] = []
+ let lastIndex = 0
+
+ for (const ref of refs) {
+ const start = ref.source?.text?.start
+ const end = ref.source?.text?.end
+
+ if (start === undefined || end === undefined || start < lastIndex) continue
+
+ if (start > lastIndex) {
+ result.push({ text: text.slice(lastIndex, start) })
+ }
+
+ result.push({ text: text.slice(start, end), highlight: true })
+ lastIndex = end
+ }
+
+ if (lastIndex < text.length) {
+ result.push({ text: text.slice(lastIndex) })
+ }
+
+ return result
+ })
+
+ return (
+ <For each={segments()}>
+ {(segment) => <span classList={{ "text-text-strong font-medium": segment.highlight }}>{segment.text}</span>}
+ </For>
)
- return <div data-component="user-message">{text()}</div>
}
export function Part(props: MessagePartProps) {
@@ -220,8 +301,12 @@ ToolRegistry.register({
render(props) {
return (
<BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}>
- <Show when={false && props.output}>
- <div data-component="tool-output">{props.output}</div>
+ <Show when={props.output}>
+ {(output) => (
+ <div data-component="tool-output" data-scrollable>
+ <Markdown text={output()} />
+ </div>
+ )}
</Show>
</BasicTool>
)
@@ -240,8 +325,12 @@ ToolRegistry.register({
args: props.input.pattern ? ["pattern=" + props.input.pattern] : [],
}}
>
- <Show when={false && props.output}>
- <div data-component="tool-output">{props.output}</div>
+ <Show when={props.output}>
+ {(output) => (
+ <div data-component="tool-output" data-scrollable>
+ <Markdown text={output()} />
+ </div>
+ )}
</Show>
</BasicTool>
)
@@ -263,8 +352,12 @@ ToolRegistry.register({
args,
}}
>
- <Show when={false && props.output}>
- <div data-component="tool-output">{props.output}</div>
+ <Show when={props.output}>
+ {(output) => (
+ <div data-component="tool-output" data-scrollable>
+ <Markdown text={output()} />
+ </div>
+ )}
</Show>
</BasicTool>
)
@@ -288,8 +381,12 @@ ToolRegistry.register({
),
}}
>
- <Show when={false && props.output}>
- <div data-component="tool-output">{props.output}</div>
+ <Show when={props.output}>
+ {(output) => (
+ <div data-component="tool-output" data-scrollable>
+ <Markdown text={output()} />
+ </div>
+ )}
</Show>
</BasicTool>
)
@@ -308,8 +405,12 @@ ToolRegistry.register({
subtitle: props.input.description,
}}
>
- <Show when={false && props.output}>
- <div data-component="tool-output">{props.output}</div>
+ <Show when={props.output}>
+ {(output) => (
+ <div data-component="tool-output" data-scrollable>
+ <Markdown text={output()} />
+ </div>
+ )}
</Show>
</BasicTool>
)
@@ -387,6 +488,7 @@ ToolRegistry.register({
ToolRegistry.register({
name: "write",
render(props) {
+ console.log(props)
return (
<BasicTool
icon="code-lines"
@@ -405,9 +507,19 @@ ToolRegistry.register({
</div>
}
>
- <Show when={false && props.output}>
- <div data-component="tool-output">{props.output}</div>
- </Show>
+ {/* <Show when={props.input.content}> */}
+ {/* <div data-component="write-content"> */}
+ {/* <Code */}
+ {/* file={{ */}
+ {/* name: props.input.filePath, */}
+ {/* contents: props.input.content, */}
+ {/* cacheKey: checksum(props.input.content), */}
+ {/* }} */}
+ {/* overflow="scroll" */}
+ {/* class="pb-40" */}
+ {/* /> */}
+ {/* </div> */}
+ {/* </Show> */}
</BasicTool>
)
},
@@ -418,6 +530,7 @@ ToolRegistry.register({
render(props) {
return (
<BasicTool
+ defaultOpen
icon="checklist"
trigger={{
title: "To-dos",
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 8009091b7..b47ab55b1 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { For, Match, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { type FileDiff } from "@opencode-ai/sdk/v2"
-import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
+import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { Dynamic } from "solid-js/web"
import { checksum } from "@opencode-ai/util/encode"
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index ad2e6c36e..0f5a26a2a 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -24,6 +24,8 @@ export function SessionTurn(
props: ParentProps<{
sessionID: string
messageID: string
+ stepsExpanded?: boolean
+ onStepsExpandedChange?: (expanded: boolean) => void
classes?: {
root?: string
content?: string
@@ -60,11 +62,12 @@ export function SessionTurn(
function handleScroll() {
if (!scrollRef) return
- // prevents scroll loops
- if (working() && scrollRef.scrollTop < 100) return
- setState("scrollY", scrollRef.scrollTop)
if (state.autoScrolling) return
const { scrollTop, scrollHeight, clientHeight } = scrollRef
+ const scrollRoom = scrollHeight - clientHeight
+ if (scrollRoom > 100) {
+ setState("scrollY", scrollTop)
+ }
const atBottom = scrollHeight - scrollTop - clientHeight < 50
if (!atBottom && working()) {
setState("userScrolled", true)
@@ -222,11 +225,17 @@ export function SessionTurn(
const [store, setStore] = createStore({
status: rawStatus(),
- stepsExpanded: true,
+ stepsExpanded: props.stepsExpanded ?? working(),
duration: duration(),
})
createEffect(() => {
+ if (props.stepsExpanded !== undefined) {
+ setStore("stepsExpanded", props.stepsExpanded)
+ }
+ })
+
+ createEffect(() => {
const timer = setInterval(() => {
setStore("duration", duration())
}, 1000)
@@ -260,8 +269,13 @@ export function SessionTurn(
createEffect((prev) => {
const isWorking = working()
+ if (!prev && isWorking) {
+ setStore("stepsExpanded", true)
+ props.onStepsExpandedChange?.(true)
+ }
if (prev && !isWorking && !state.userScrolled) {
setStore("stepsExpanded", false)
+ props.onStepsExpandedChange?.(false)
}
return isWorking
}, working())
@@ -278,7 +292,7 @@ export function SessionTurn(
<div data-slot="session-turn-message-header">
<div data-slot="session-turn-message-title">
<Switch>
- <Match when={working()}>
+ <Match when={working() && message().id === userMessages().at(-1)?.id}>
<Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
</Match>
<Match when={true}>
@@ -298,7 +312,11 @@ export function SessionTurn(
data-slot="session-turn-collapsible-trigger-content"
variant="ghost"
size="small"
- onClick={() => setStore("stepsExpanded", !store.stepsExpanded)}
+ onClick={() => {
+ const next = !store.stepsExpanded
+ setStore("stepsExpanded", next)
+ props.onStepsExpandedChange?.(next)
+ }}
>
<Show when={working()}>
<Spinner />