summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-15 09:34:00 -0600
committerAdam <[email protected]>2025-12-15 10:22:04 -0600
commit5cf6a1343c6ca088bd2b586197faf7fe58961290 (patch)
treed8001631005d2f4791bfe3a0dd3a0b21003a2516 /packages/ui/src
parent44d6c5780d41616bf29a749020c9d7f98895407f (diff)
downloadopencode-5cf6a1343c6ca088bd2b586197faf7fe58961290.tar.gz
opencode-5cf6a1343c6ca088bd2b586197faf7fe58961290.zip
wip(desktop): progress
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/components/message-part.css76
-rw-r--r--packages/ui/src/components/message-part.tsx96
-rw-r--r--packages/ui/src/components/session-turn.tsx10
3 files changed, 166 insertions, 16 deletions
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index b66ef1d27..e2d70e342 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -14,11 +14,77 @@
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;
+ 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"] {
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index f00c43bd8..5ea3cd19c 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) {
@@ -303,9 +384,8 @@ ToolRegistry.register({
<BasicTool
icon="task"
trigger={{
- title: `${props.input.subagent_type || props.tool} Agent`,
+ title: `${props.input.subagent_type || props.tool} Agent: ${props.input.description || ""}`,
titleClass: "capitalize",
- subtitle: props.input.description,
}}
>
<Show when={false && props.output}>
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index f905abbd1..2df324eef 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -62,11 +62,15 @@ 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
+ // prevents scroll loops - only update scrollY if we have meaningful scroll room
+ // the gap clamp shrinks by 0.48px per pixel scrolled, hitting min at ~71px scroll
+ // we need at least that much scroll headroom beyond the current scroll position
+ const scrollRoom = scrollHeight - clientHeight
+ if (scrollRoom > 100) {
+ setState("scrollY", scrollTop)
+ }
const atBottom = scrollHeight - scrollTop - clientHeight < 50
if (!atBottom && working()) {
setState("userScrolled", true)