fix(app): new layout improvements

This commit is contained in:
Adam
2026-01-18 05:26:24 -06:00
parent befd0f1636
commit 7811e01c8e
4 changed files with 239 additions and 187 deletions

View File

@@ -46,6 +46,7 @@ import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
interface Diagnostic {
range: {
@@ -297,6 +298,23 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
const dialog = useDialog()
const [copied, setCopied] = createSignal(false)
const [expanded, setExpanded] = createSignal(false)
const [canExpand, setCanExpand] = createSignal(false)
let textRef: HTMLDivElement | undefined
const updateCanExpand = () => {
const el = textRef
if (!el) return
if (expanded()) return
setCanExpand(el.scrollHeight > el.clientHeight + 2)
}
createResizeObserver(
() => textRef,
() => {
updateCanExpand()
},
)
const textPart = createMemo(
() => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
@@ -304,6 +322,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const text = createMemo(() => textPart()?.text || "")
createEffect(() => {
text()
updateCanExpand()
})
const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
const attachments = createMemo(() =>
@@ -335,7 +358,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
}
return (
<div data-component="user-message">
<div data-component="user-message" data-expanded={expanded()} data-can-expand={canExpand()}>
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
@@ -365,8 +388,16 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
</div>
</Show>
<Show when={text()}>
<div data-slot="user-message-text">
<div data-slot="user-message-text" ref={(el) => (textRef = el)}>
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
<button
data-slot="user-message-expand"
type="button"
aria-label={expanded() ? "Collapse message" : "Expand message"}
onClick={() => setExpanded((v) => !v)}
>
<Icon name="chevron-down" size="small" />
</button>
<div data-slot="user-message-copy-wrapper">
<Tooltip value={copied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
<IconButton icon={copied() ? "check" : "copy"} variant="secondary" onClick={handleCopy} />

View File

@@ -44,23 +44,33 @@
}
}
[data-slot="session-turn-sticky-title"] {
width: 100%;
[data-slot="session-turn-sticky"] {
width: calc(100% + 9px);
position: sticky;
top: 0;
top: var(--session-title-height, 0px);
z-index: 20;
background-color: var(--background-stronger);
z-index: 21;
margin-left: -9px;
padding-left: 9px;
padding-bottom: 12px;
display: flex;
flex-direction: column;
gap: 12px;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: var(--background-stronger);
z-index: -1;
}
}
[data-slot="session-turn-response-trigger"] {
position: sticky;
top: calc(var(--sticky-header-height, 0px));
background-color: var(--background-stronger);
z-index: 20;
width: calc(100% + 9px);
margin-left: -9px;
padding-left: 9px;
padding-bottom: 8px;
width: fit-content;
}
[data-slot="session-turn-message-header"] {
@@ -75,6 +85,61 @@
max-width: 100%;
}
[data-component="user-message"] [data-slot="user-message-text"] {
max-height: var(--user-message-collapsed-height, 64px);
}
[data-component="user-message"][data-expanded="true"] [data-slot="user-message-text"] {
max-height: none;
}
[data-component="user-message"][data-can-expand="true"] [data-slot="user-message-text"] {
padding-right: 36px;
padding-bottom: 28px;
}
[data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"] {
display: none;
position: absolute;
bottom: 6px;
right: 6px;
padding: 0;
}
[data-component="user-message"][data-can-expand="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"],
[data-component="user-message"][data-expanded="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"] {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
width: 22px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
color: var(--text-weak);
[data-slot="icon-svg"] {
transition: transform 0.15s ease;
}
}
[data-component="user-message"][data-expanded="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"]
[data-slot="icon-svg"] {
transform: rotate(180deg);
}
[data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"]:hover {
background: var(--surface-raised-base);
color: var(--text-base);
}
[data-slot="session-turn-user-badges"] {
display: flex;
align-items: center;
@@ -266,11 +331,7 @@
}
[data-component="sticky-accordion-header"] {
top: var(--sticky-header-height, 40px);
&[data-expanded]::before {
top: calc(-1 * var(--sticky-header-height, 40px));
}
position: static;
}
[data-slot="session-turn-accordion-trigger-content"] {

View File

@@ -13,17 +13,13 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Binary } from "@opencode-ai/util/binary"
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DiffChanges } from "./diff-changes"
import { Typewriter } from "./typewriter"
import { Message, Part } from "./message-part"
import { Markdown } from "./markdown"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { ProviderIcon } from "./provider-icon"
import type { IconName } from "./provider-icons/types"
import { IconButton } from "./icon-button"
import { Tooltip } from "./tooltip"
import { Card } from "./card"
@@ -331,8 +327,6 @@ export function SessionTurn(
const response = createMemo(() => lastTextPart()?.text)
const responsePartId = createMemo(() => lastTextPart()?.id)
const sessionInfo = createMemo(() => data.store.session.find((item) => item.id === props.sessionID))
const sessionTitle = createMemo(() => props.sessionTitle ?? sessionInfo()?.title)
const hasDiffs = createMemo(() => (data.store.session_diff?.[props.sessionID]?.length ?? 0) > 0)
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
@@ -371,15 +365,11 @@ export function SessionTurn(
const diffBatch = 20
const [store, setStore] = createStore({
stickyTitleRef: undefined as HTMLDivElement | undefined,
stickyTriggerRef: undefined as HTMLDivElement | undefined,
stickyHeaderHeight: 0,
retrySeconds: 0,
diffsOpen: [] as string[],
diffLimit: diffInit,
status: rawStatus(),
duration: duration(),
titleShown: false,
})
createEffect(
@@ -393,18 +383,6 @@ export function SessionTurn(
),
)
createEffect(() => {
if (!sessionTitle()) {
setStore("titleShown", false)
return
}
if (store.titleShown) return
const first = allMessages().find((item) => item?.role === "user")
if (!first) return
if (first.id !== props.messageID) return
setStore("titleShown", true)
})
createEffect(() => {
const r = retry()
if (!r) {
@@ -420,22 +398,6 @@ export function SessionTurn(
onCleanup(() => clearInterval(timer))
})
createResizeObserver(
() => store.stickyTitleRef,
({ height }) => {
const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0
setStore("stickyHeaderHeight", height + triggerHeight + 8)
},
)
createResizeObserver(
() => store.stickyTriggerRef,
({ height }) => {
const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0
setStore("stickyHeaderHeight", titleHeight + height + 8)
},
)
createEffect(() => {
const timer = setInterval(() => {
setStore("duration", duration())
@@ -491,99 +453,58 @@ export function SessionTurn(
data-message={msg().id}
data-slot="session-turn-message-container"
class={props.classes?.container}
style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }}
>
<Switch>
<Match when={isShellMode()}>
<Part part={shellModePart()!} message={msg()} defaultOpen />
</Match>
<Match when={true}>
<Show when={sessionTitle() && store.titleShown}>
<div ref={(el) => setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
<div data-slot="session-turn-message-header">
<div data-slot="session-turn-message-title">
<div data-slot="session-turn-sticky">
{/* User Message */}
<div data-slot="session-turn-message-content">
<Message message={msg()} parts={parts()} />
</div>
{/* Trigger (sticky) */}
<Show when={working() || hasSteps()}>
<div data-slot="session-turn-response-trigger">
<Button
data-expandable={assistantMessages().length > 0}
data-slot="session-turn-collapsible-trigger-content"
variant="ghost"
size="small"
onClick={props.onStepsExpandedToggle ?? (() => {})}
>
<Show when={working()}>
<Spinner />
</Show>
<Switch>
<Match when={working()}>
<Typewriter as="h1" text={sessionTitle()} data-slot="session-turn-typewriter" />
</Match>
<Match when={true}>
<h1>{sessionTitle()}</h1>
<Match when={retry()}>
<span data-slot="session-turn-retry-message">
{(() => {
const r = retry()
if (!r) return ""
return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message
})()}
</span>
<span data-slot="session-turn-retry-seconds">
· retrying {store.retrySeconds > 0 ? `in ${store.retrySeconds}s ` : ""}
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match>
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
<Match when={props.stepsExpanded}>Hide steps</Match>
<Match when={!props.stepsExpanded}>Show steps</Match>
</Switch>
</div>
<span>·</span>
<span>{store.duration}</span>
<Show when={assistantMessages().length > 0}>
<Icon name="chevron-grabber-vertical" size="small" />
</Show>
</Button>
</div>
</div>
</Show>
<Show
when={
(msg() as UserMessage).agent ||
(msg() as UserMessage).model?.modelID ||
(msg() as UserMessage).variant
}
>
<div data-slot="session-turn-user-badges">
<Show when={(msg() as UserMessage).agent}>
<span data-slot="session-turn-badge">{(msg() as UserMessage).agent}</span>
</Show>
<Show when={(msg() as UserMessage).model?.modelID}>
<span data-slot="session-turn-badge" class="inline-flex items-center gap-1">
<ProviderIcon
id={(msg() as UserMessage).model!.providerID as IconName}
class="size-3.5 shrink-0"
/>
{(msg() as UserMessage).model?.modelID}
</span>
</Show>
<Show when={(msg() as UserMessage).variant}>
<span data-slot="session-turn-badge">{(msg() as UserMessage).variant}</span>
</Show>
</div>
</Show>
{/* User Message */}
<div data-slot="session-turn-message-content">
<Message message={msg()} parts={parts()} />
</Show>
</div>
{/* Trigger (sticky) */}
<Show when={working() || hasSteps()}>
<div ref={(el) => setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
<Button
data-expandable={assistantMessages().length > 0}
data-slot="session-turn-collapsible-trigger-content"
variant="ghost"
size="small"
onClick={props.onStepsExpandedToggle ?? (() => {})}
>
<Show when={working()}>
<Spinner />
</Show>
<Switch>
<Match when={retry()}>
<span data-slot="session-turn-retry-message">
{(() => {
const r = retry()
if (!r) return ""
return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message
})()}
</span>
<span data-slot="session-turn-retry-seconds">
· retrying {store.retrySeconds > 0 ? `in ${store.retrySeconds}s ` : ""}
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match>
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
<Match when={props.stepsExpanded}>Hide steps</Match>
<Match when={!props.stepsExpanded}>Show steps</Match>
</Switch>
<span>·</span>
<span>{store.duration}</span>
<Show when={assistantMessages().length > 0}>
<Icon name="chevron-grabber-vertical" size="small" />
</Show>
</Button>
</div>
</Show>
{/* Response */}
<Show when={props.stepsExpanded && assistantMessages().length > 0}>
<div data-slot="session-turn-collapsible-content-inner">