summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-18 05:26:24 -0600
committerAdam <[email protected]>2026-01-19 10:55:57 -0600
commit7811e01c8efc57d56b91547463c707baf2eb6815 (patch)
tree8a7a715722cb07b95e0ae56b708913af38fa2146 /packages/ui/src
parentbefd0f16362678dcd99cd9118cbcb044997c9511 (diff)
downloadopencode-7811e01c8efc57d56b91547463c707baf2eb6815.tar.gz
opencode-7811e01c8efc57d56b91547463c707baf2eb6815.zip
fix(app): new layout improvements
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/components/message-part.tsx35
-rw-r--r--packages/ui/src/components/session-turn.css95
-rw-r--r--packages/ui/src/components/session-turn.tsx161
3 files changed, 152 insertions, 139 deletions
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 47403786b..b3fd01c2d 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -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} />
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index f7ab97179..a3c87c576 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -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"] {
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 8b807af82..e5fe4ba1c 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -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(
@@ -394,18 +384,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) {
setStore("retrySeconds", 0)
@@ -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">