diff options
| author | Adam <[email protected]> | 2025-12-15 09:34:00 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-15 10:22:04 -0600 |
| commit | 5cf6a1343c6ca088bd2b586197faf7fe58961290 (patch) | |
| tree | d8001631005d2f4791bfe3a0dd3a0b21003a2516 /packages/ui/src | |
| parent | 44d6c5780d41616bf29a749020c9d7f98895407f (diff) | |
| download | opencode-5cf6a1343c6ca088bd2b586197faf7fe58961290.tar.gz opencode-5cf6a1343c6ca088bd2b586197faf7fe58961290.zip | |
wip(desktop): progress
Diffstat (limited to 'packages/ui/src')
| -rw-r--r-- | packages/ui/src/components/message-part.css | 76 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 96 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 10 |
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) |
